Skip to content

Commit bf380fb

Browse files
authored
Feat: adding multipart support (#8)
1 parent f8e4dbe commit bf380fb

File tree

17 files changed

+743
-21
lines changed

17 files changed

+743
-21
lines changed

Package.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ let package = Package(
2424
dependencies: []),
2525
.testTarget(
2626
name: "SimpleHTTPTests",
27-
dependencies: ["SimpleHTTP"]),
27+
dependencies: ["SimpleHTTP"],
28+
resources: [
29+
.copy("Ressources/Images/swift.png"),
30+
.copy("Ressources/Images/swiftUI.png")
31+
]
32+
),
2833
]
2934
)

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,62 @@ A few words about Session:
5656
- You can skip encoder and decoder if you use JSON
5757
- You can provide a custom `URLSession` instance if ever needed
5858

59+
## Send a body
60+
61+
### Encodable
62+
63+
You will build your request by sending your `body` to construct it:
64+
65+
```swift
66+
struct UserBody: Encodable {}
67+
68+
extension Request {
69+
static func login(_ body: UserBody) -> Self where Output == LoginResponse {
70+
.post("login", body: .encodable(body))
71+
}
72+
}
73+
```
74+
75+
We defined a `login(_:)` request which will request login endpoint by sending a `UserBody` and waiting for a `LoginResponse`
76+
77+
### Multipart
78+
79+
You we build 2 requests:
80+
81+
- send `URL`
82+
- send a `Data`
83+
84+
```swift
85+
extension Request {
86+
static func send(audio: URL) throws -> Self where Output == SendAudioResponse {
87+
var multipart = MultipartFormData()
88+
try multipart.add(url: audio, name: "define_your_name")
89+
return .post("sendAudio", body: .multipart(multipart))
90+
}
91+
92+
static func send(audio: Data) throws -> Self where Output == SendAudioResponse {
93+
var multipart = MultipartFormData()
94+
try multipart.add(data: data, name: "your_name", fileName: "your_fileName", mimeType: "right_mimeType")
95+
return .post("sendAudio", body: .multipart(multipart))
96+
}
97+
}
98+
```
99+
100+
We defined the 2 `send(audio:)` requests which will request `sendAudio` endpoint by sending an `URL` or a `Data` and waiting for a `SendAudioResponse`
101+
102+
We can add multiple `Data`/`URL` to the multipart
103+
104+
```swift
105+
extension Request {
106+
static func send(audio: URL, image: Data) throws -> Self where Output == SendAudioImageResponse {
107+
var multipart = MultipartFormData()
108+
try multipart.add(url: audio, name: "define_your_name")
109+
try multipart.add(data: image, name: "your_name", fileName: "your_fileName", mimeType: "right_mimeType")
110+
return .post("sendAudioImage", body: .multipart(multipart))
111+
}
112+
}
113+
```
114+
59115
## Interceptor
60116

61117
Protocol `Interceptor` enable powerful request interceptions. This include authentication, logging, request retrying, etc...
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Foundation
2+
3+
struct MultipartFormDataEncoder {
4+
5+
let boundary: String
6+
private var bodyParts: [BodyPart]
7+
8+
//
9+
// The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more
10+
// information, please refer to the following article:
11+
// - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html
12+
//
13+
private let streamBufferSize = 1024
14+
15+
public init(body: MultipartFormData) {
16+
self.boundary = body.boundary
17+
self.bodyParts = body.bodyParts
18+
}
19+
20+
mutating func encode() throws -> Data {
21+
var encoded = Data()
22+
23+
if var first = bodyParts.first {
24+
first.hasInitialBoundary = true
25+
bodyParts[0] = first
26+
}
27+
28+
if var last = bodyParts.last {
29+
last.hasFinalBoundary = true
30+
bodyParts[bodyParts.count - 1] = last
31+
}
32+
33+
for bodyPart in bodyParts {
34+
encoded.append(try encodeBodyPart(bodyPart))
35+
}
36+
37+
return encoded
38+
}
39+
40+
private func encodeBodyPart(_ bodyPart: BodyPart) throws -> Data {
41+
var encoded = Data()
42+
43+
if bodyPart.hasInitialBoundary {
44+
encoded.append(Boundary.data(for: .initial, boundary: boundary))
45+
} else {
46+
encoded.append(Boundary.data(for: .encapsulated, boundary: boundary))
47+
}
48+
49+
encoded.append(try encodeBodyPart(headers: bodyPart.headers))
50+
encoded.append(try encodeBodyPart(stream: bodyPart.stream, length: bodyPart.length))
51+
52+
if bodyPart.hasFinalBoundary {
53+
encoded.append(Boundary.data(for: .final, boundary: boundary))
54+
}
55+
56+
return encoded
57+
}
58+
59+
private func encodeBodyPart(headers: [Header]) throws -> Data {
60+
let headerText = headers.map { "\($0.name.key): \($0.value)\(EncodingCharacters.crlf)" }
61+
.joined()
62+
+ EncodingCharacters.crlf
63+
64+
return Data(headerText.utf8)
65+
}
66+
67+
private func encodeBodyPart(stream: InputStream, length: Int) throws -> Data {
68+
var encoded = Data()
69+
70+
stream.open()
71+
defer { stream.close() }
72+
73+
while stream.hasBytesAvailable {
74+
var buffer = [UInt8](repeating: 0, count: streamBufferSize)
75+
let bytesRead = stream.read(&buffer, maxLength: streamBufferSize)
76+
77+
if let error = stream.streamError {
78+
throw BodyPart.Error.inputStreamReadFailed(error.localizedDescription)
79+
}
80+
81+
if bytesRead > 0 {
82+
encoded.append(buffer, count: bytesRead)
83+
} else {
84+
break
85+
}
86+
}
87+
88+
guard encoded.count == length else {
89+
throw BodyPart.Error.unexpectedInputStreamLength(expected: length, bytesRead: encoded.count)
90+
}
91+
92+
return encoded
93+
}
94+
95+
}
96+
97+
extension BodyPart {
98+
99+
enum Error: Swift.Error {
100+
case inputStreamReadFailed(String)
101+
case unexpectedInputStreamLength(expected: Int, bytesRead: Int)
102+
}
103+
104+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
#if canImport(FoundationNetworking)
4+
import FoundationNetworking
5+
#endif
6+
7+
extension URLRequest {
8+
public mutating func multipartBody(_ body: MultipartFormData) throws {
9+
var multipartEncode = MultipartFormDataEncoder(body: body)
10+
httpBody = try multipartEncode.encode()
11+
setHeaders([.contentType: HTTPContentType.multipart(boundary: body.boundary).value])
12+
}
13+
}

Sources/SimpleHTTP/HTTP/HTTPContentType.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,8 @@ public struct HTTPContentType: Hashable, ExpressibleByStringLiteral {
1515

1616
extension HTTPContentType {
1717
public static let json: Self = "application/json"
18+
public static let octetStream: Self = "application/octet-stream"
19+
public static func multipart(boundary: String) -> Self {
20+
.init(value: "multipart/form-data; boundary=\(boundary)")
21+
}
1822
}

Sources/SimpleHTTP/HTTP/HTTPHeader.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension HTTPHeader {
1616
public static let accept: Self = "Accept"
1717
public static let authentication: Self = "Authentication"
1818
public static let contentType: Self = "Content-Type"
19+
public static var contentDisposition: Self = "Content-Disposition"
1920
}
2021

2122
@available(*, unavailable, message: "This is a reserved header. See https://developer.apple.com/documentation/foundation/nsurlrequest#1776617")

0 commit comments

Comments
 (0)