Skip to content

Commit 679d9f4

Browse files
committed
Add multipart request support
1 parent 85f4a46 commit 679d9f4

File tree

2 files changed

+182
-4
lines changed

2 files changed

+182
-4
lines changed

Sources/HandySwift/Types/RESTClient.swift

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,23 @@ public final class RESTClient: Sendable {
7373
case json(Encodable & Sendable)
7474
case string(String)
7575
case form([URLQueryItem])
76+
case multipart([MultipartItem], requestID: UUID)
7677

7778
var contentType: String {
7879
switch self {
79-
case .binary: return "application/octet-stream"
80-
case .json: return "application/json"
81-
case .string: return "text/plain"
82-
case .form: return "application/x-www-form-urlencoded"
80+
case .binary: "application/octet-stream"
81+
case .json: "application/json"
82+
case .string: "text/plain"
83+
case .form: "application/x-www-form-urlencoded"
84+
case .multipart(_, let requestID): "multipart/form-data; boundary=handy-swift-boundary-\(requestID.uuidString)"
8385
}
8486
}
8587

88+
/// Creates a multipart body with a generated UUID for request identification.
89+
public static func multipart(_ items: [MultipartItem]) -> Body {
90+
.multipart(items, requestID: UUID())
91+
}
92+
8693
func httpData(jsonEncoder: JSONEncoder) throws -> Data {
8794
switch self {
8895
case .binary(let data):
@@ -98,10 +105,98 @@ public final class RESTClient: Sendable {
98105
var urlComponents = URLComponents(string: "https://example.com")!
99106
urlComponents.queryItems = queryItems
100107
return Data(urlComponents.percentEncodedQuery!.utf8)
108+
109+
case .multipart(let items, let requestID):
110+
let boundaryString = "handy-swift-boundary-\(requestID.uuidString)"
111+
var body = Data()
112+
113+
for item in items {
114+
// Add boundary separator
115+
body.append(Data("--\(boundaryString)\r\n".utf8))
116+
117+
// Add Content-Disposition header
118+
var contentDisposition = "Content-Disposition: form-data; name=\"\(item.name)\""
119+
120+
switch item.value {
121+
case .text(let text):
122+
body.append(Data("\(contentDisposition)\r\n\r\n".utf8))
123+
body.append(Data(text.utf8))
124+
125+
case .data(let data, let fileName, let mimeType):
126+
if let fileName = fileName {
127+
contentDisposition += "; filename=\"\(fileName)\""
128+
}
129+
body.append(Data("\(contentDisposition)\r\n".utf8))
130+
131+
if let mimeType = mimeType {
132+
body.append(Data("Content-Type: \(mimeType)\r\n".utf8))
133+
}
134+
135+
body.append(Data("\r\n".utf8))
136+
body.append(data)
137+
138+
case .json(let json):
139+
body.append(Data("\(contentDisposition)\r\n".utf8))
140+
body.append(Data("Content-Type: application/json\r\n\r\n".utf8))
141+
let jsonData = try jsonEncoder.encode(json)
142+
body.append(jsonData)
143+
}
144+
145+
body.append(Data("\r\n".utf8))
146+
}
147+
148+
// Add final boundary
149+
body.append(Data("--\(boundaryString)--\r\n".utf8))
150+
151+
return body
101152
}
102153
}
103154
}
104155

156+
/// A name-value pair for multipart/form-data requests, following the URLQueryItem pattern.
157+
/// Used to construct multipart request bodies in a type-safe, explorable way.
158+
///
159+
/// Example usage:
160+
/// ```swift
161+
/// let multipartItems: [RESTClient.MultipartItem] = [
162+
/// .init(name: "model", value: .text("gpt-image-1")),
163+
/// .init(name: "prompt", value: .text("Generate a liquid glass icon")),
164+
/// .init(name: "image", value: .data(imageData, fileName: "input.png", mimeType: "image/png"))
165+
/// ]
166+
/// ```
167+
public struct MultipartItem: Sendable {
168+
/// The name of the form field.
169+
public let name: String
170+
171+
/// The value of the form field, which can be text, binary data, or JSON.
172+
public let value: MultipartValue
173+
174+
/// Creates a new multipart item with the specified name and value.
175+
/// - Parameters:
176+
/// - name: The name of the form field
177+
/// - value: The value of the form field
178+
public init(name: String, value: MultipartValue) {
179+
self.name = name
180+
self.value = value
181+
}
182+
}
183+
184+
/// The value types supported in multipart/form-data requests.
185+
public enum MultipartValue: Sendable {
186+
/// A plain text value.
187+
case text(String)
188+
189+
/// Binary data with optional filename and MIME type.
190+
/// - Parameters:
191+
/// - data: The binary data to include
192+
/// - fileName: Optional filename for the uploaded file
193+
/// - mimeType: Optional MIME type (e.g., "image/png", "application/json")
194+
case data(Data, fileName: String?, mimeType: String?)
195+
196+
/// A JSON-encodable value that will be serialized to JSON.
197+
case json(Encodable & Sendable)
198+
}
199+
105200
public protocol RequestPlugin: Sendable {
106201
func apply(to request: inout URLRequest)
107202
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import HandySwift
5+
6+
@Suite("RESTClient Tests")
7+
struct RESTClientTests {
8+
9+
@Test("Complete multipart form-data integration")
10+
func completeMultipartFormDataIntegration() throws {
11+
struct TestModel: Codable, Sendable {
12+
let name: String
13+
let value: Int
14+
}
15+
16+
let imageData = Data(repeating: 0xFF, count: 100)
17+
let testModel = TestModel(name: "test", value: 42)
18+
19+
let multipartItems: [RESTClient.MultipartItem] = [
20+
.init(name: "model", value: .text("gpt-image-1")),
21+
.init(name: "prompt", value: .text("Generate a liquid glass icon")),
22+
.init(name: "image", value: .data(imageData, fileName: "input.png", mimeType: "image/png")),
23+
.init(name: "settings", value: .json(testModel)),
24+
.init(name: "file_no_meta", value: .data(Data("content".utf8), fileName: nil, mimeType: nil)),
25+
]
26+
27+
let body = RESTClient.Body.multipart(multipartItems)
28+
let contentType = body.contentType
29+
let httpData = try body.httpData(jsonEncoder: JSONEncoder())
30+
let dataString = String(decoding: httpData, as: UTF8.self)
31+
32+
// Extract boundary for testing
33+
#expect(contentType.hasPrefix("multipart/form-data; boundary="))
34+
let boundary = String(contentType.dropFirst("multipart/form-data; boundary=".count))
35+
#expect(boundary.hasPrefix("handy-swift-boundary-"))
36+
37+
// Build expected multipart structure with actual binary data
38+
let imageDataString = String(decoding: imageData, as: UTF8.self) // Convert the actual imageData to string
39+
let expectedStructure = """
40+
--\(boundary)\r
41+
Content-Disposition: form-data; name="model"\r
42+
\r
43+
gpt-image-1\r
44+
--\(boundary)\r
45+
Content-Disposition: form-data; name="prompt"\r
46+
\r
47+
Generate a liquid glass icon\r
48+
--\(boundary)\r
49+
Content-Disposition: form-data; name="image"; filename="input.png"\r
50+
Content-Type: image/png\r
51+
\r
52+
\(imageDataString)\r
53+
--\(boundary)\r
54+
Content-Disposition: form-data; name="settings"\r
55+
Content-Type: application/json\r
56+
\r
57+
{"name":"test","value":42}\r
58+
--\(boundary)\r
59+
Content-Disposition: form-data; name="file_no_meta"\r
60+
\r
61+
content\r
62+
--\(boundary)--\r
63+
64+
"""
65+
66+
// Verify the complete structure matches exactly what we expect
67+
#expect(dataString == expectedStructure)
68+
69+
// Additional RFC 2046 compliance checks
70+
#expect(dataString.contains("\r\n"))
71+
#expect(!dataString.replacingOccurrences(of: "\r\n", with: "").contains("\n"))
72+
#expect(dataString.hasSuffix("--\(boundary)--\r\n"))
73+
74+
// Verify boundary consistency and uniqueness
75+
#expect(dataString.components(separatedBy: "--\(boundary)").count >= 6) // 5 items + final boundary
76+
77+
let body2 = RESTClient.Body.multipart(multipartItems)
78+
let boundary2 = String(body2.contentType.dropFirst("multipart/form-data; boundary=".count))
79+
#expect(boundary != boundary2) // Different instances have unique boundaries
80+
81+
#expect(body.contentType == body.contentType) // Same instance maintains consistent boundary
82+
}
83+
}

0 commit comments

Comments
 (0)