Skip to content

Commit 49390b5

Browse files
committed
Add multipart request support
1 parent 85f4a46 commit 49390b5

File tree

2 files changed

+197
-4
lines changed

2 files changed

+197
-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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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: "quality", value: .text("high")),
23+
.init(name: "style", value: .text("natural")),
24+
.init(name: "size", value: .text("1024x1024")),
25+
.init(name: "image", value: .data(imageData, fileName: "input.png", mimeType: "image/png")),
26+
.init(name: "settings", value: .json(testModel)),
27+
.init(name: "file_no_meta", value: .data(Data("content".utf8), fileName: nil, mimeType: nil)),
28+
]
29+
30+
let body = RESTClient.Body.multipart(multipartItems)
31+
let contentType = body.contentType
32+
let httpData = try body.httpData(jsonEncoder: JSONEncoder())
33+
let dataString = String(decoding: httpData, as: UTF8.self)
34+
35+
// Extract boundary for testing
36+
#expect(contentType.hasPrefix("multipart/form-data; boundary="))
37+
let boundary = String(contentType.dropFirst("multipart/form-data; boundary=".count))
38+
#expect(boundary.hasPrefix("handy-swift-boundary-"))
39+
40+
// Build expected multipart structure with actual binary data
41+
let imageDataString = String(decoding: imageData, as: UTF8.self) // Convert the actual imageData to string
42+
let expectedStructure = """
43+
--\(boundary)\r
44+
Content-Disposition: form-data; name="model"\r
45+
\r
46+
gpt-image-1\r
47+
--\(boundary)\r
48+
Content-Disposition: form-data; name="prompt"\r
49+
\r
50+
Generate a liquid glass icon\r
51+
--\(boundary)\r
52+
Content-Disposition: form-data; name="quality"\r
53+
\r
54+
high\r
55+
--\(boundary)\r
56+
Content-Disposition: form-data; name="style"\r
57+
\r
58+
natural\r
59+
--\(boundary)\r
60+
Content-Disposition: form-data; name="size"\r
61+
\r
62+
1024x1024\r
63+
--\(boundary)\r
64+
Content-Disposition: form-data; name="image"; filename="input.png"\r
65+
Content-Type: image/png\r
66+
\r
67+
\(imageDataString)\r
68+
--\(boundary)\r
69+
Content-Disposition: form-data; name="settings"\r
70+
Content-Type: application/json\r
71+
\r
72+
{"name":"test","value":42}\r
73+
--\(boundary)\r
74+
Content-Disposition: form-data; name="file_no_meta"\r
75+
\r
76+
content\r
77+
--\(boundary)--\r
78+
79+
"""
80+
81+
// Verify the complete structure matches exactly what we expect
82+
#expect(dataString == expectedStructure)
83+
84+
// Additional RFC 2046 compliance checks
85+
#expect(dataString.contains("\r\n"))
86+
#expect(!dataString.replacingOccurrences(of: "\r\n", with: "").contains("\n"))
87+
#expect(dataString.hasSuffix("--\(boundary)--\r\n"))
88+
89+
// Verify boundary consistency and uniqueness
90+
#expect(dataString.components(separatedBy: "--\(boundary)").count >= 9) // 8 items + final boundary
91+
92+
let body2 = RESTClient.Body.multipart(multipartItems)
93+
let boundary2 = String(body2.contentType.dropFirst("multipart/form-data; boundary=".count))
94+
#expect(boundary != boundary2) // Different instances have unique boundaries
95+
96+
#expect(body.contentType == body.contentType) // Same instance maintains consistent boundary
97+
}
98+
}

0 commit comments

Comments
 (0)