Skip to content

Commit

Permalink
Feat: adding multipart support (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
lowki93 committed Jul 23, 2022
1 parent f8e4dbe commit bf380fb
Show file tree
Hide file tree
Showing 17 changed files with 743 additions and 21 deletions.
7 changes: 6 additions & 1 deletion Package.swift
Expand Up @@ -24,6 +24,11 @@ let package = Package(
dependencies: []),
.testTarget(
name: "SimpleHTTPTests",
dependencies: ["SimpleHTTP"]),
dependencies: ["SimpleHTTP"],
resources: [
.copy("Ressources/Images/swift.png"),
.copy("Ressources/Images/swiftUI.png")
]
),
]
)
56 changes: 56 additions & 0 deletions README.md
Expand Up @@ -56,6 +56,62 @@ A few words about Session:
- You can skip encoder and decoder if you use JSON
- You can provide a custom `URLSession` instance if ever needed

## Send a body

### Encodable

You will build your request by sending your `body` to construct it:

```swift
struct UserBody: Encodable {}

extension Request {
static func login(_ body: UserBody) -> Self where Output == LoginResponse {
.post("login", body: .encodable(body))
}
}
```

We defined a `login(_:)` request which will request login endpoint by sending a `UserBody` and waiting for a `LoginResponse`

### Multipart

You we build 2 requests:

- send `URL`
- send a `Data`

```swift
extension Request {
static func send(audio: URL) throws -> Self where Output == SendAudioResponse {
var multipart = MultipartFormData()
try multipart.add(url: audio, name: "define_your_name")
return .post("sendAudio", body: .multipart(multipart))
}

static func send(audio: Data) throws -> Self where Output == SendAudioResponse {
var multipart = MultipartFormData()
try multipart.add(data: data, name: "your_name", fileName: "your_fileName", mimeType: "right_mimeType")
return .post("sendAudio", body: .multipart(multipart))
}
}
```

We defined the 2 `send(audio:)` requests which will request `sendAudio` endpoint by sending an `URL` or a `Data` and waiting for a `SendAudioResponse`

We can add multiple `Data`/`URL` to the multipart

```swift
extension Request {
static func send(audio: URL, image: Data) throws -> Self where Output == SendAudioImageResponse {
var multipart = MultipartFormData()
try multipart.add(url: audio, name: "define_your_name")
try multipart.add(data: image, name: "your_name", fileName: "your_fileName", mimeType: "right_mimeType")
return .post("sendAudioImage", body: .multipart(multipart))
}
}
```

## Interceptor

Protocol `Interceptor` enable powerful request interceptions. This include authentication, logging, request retrying, etc...
Expand Down
104 changes: 104 additions & 0 deletions Sources/SimpleHTTP/Encoder/MultipartFormDataEncoder.swift
@@ -0,0 +1,104 @@
import Foundation

struct MultipartFormDataEncoder {

let boundary: String
private var bodyParts: [BodyPart]

//
// The optimal read/write buffer size in bytes for input and output streams is 1024 (1KB). For more
// information, please refer to the following article:
// - https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/Streams/Articles/ReadingInputStreams.html
//
private let streamBufferSize = 1024

public init(body: MultipartFormData) {
self.boundary = body.boundary
self.bodyParts = body.bodyParts
}

mutating func encode() throws -> Data {
var encoded = Data()

if var first = bodyParts.first {
first.hasInitialBoundary = true
bodyParts[0] = first
}

if var last = bodyParts.last {
last.hasFinalBoundary = true
bodyParts[bodyParts.count - 1] = last
}

for bodyPart in bodyParts {
encoded.append(try encodeBodyPart(bodyPart))
}

return encoded
}

private func encodeBodyPart(_ bodyPart: BodyPart) throws -> Data {
var encoded = Data()

if bodyPart.hasInitialBoundary {
encoded.append(Boundary.data(for: .initial, boundary: boundary))
} else {
encoded.append(Boundary.data(for: .encapsulated, boundary: boundary))
}

encoded.append(try encodeBodyPart(headers: bodyPart.headers))
encoded.append(try encodeBodyPart(stream: bodyPart.stream, length: bodyPart.length))

if bodyPart.hasFinalBoundary {
encoded.append(Boundary.data(for: .final, boundary: boundary))
}

return encoded
}

private func encodeBodyPart(headers: [Header]) throws -> Data {
let headerText = headers.map { "\($0.name.key): \($0.value)\(EncodingCharacters.crlf)" }
.joined()
+ EncodingCharacters.crlf

return Data(headerText.utf8)
}

private func encodeBodyPart(stream: InputStream, length: Int) throws -> Data {
var encoded = Data()

stream.open()
defer { stream.close() }

while stream.hasBytesAvailable {
var buffer = [UInt8](repeating: 0, count: streamBufferSize)
let bytesRead = stream.read(&buffer, maxLength: streamBufferSize)

if let error = stream.streamError {
throw BodyPart.Error.inputStreamReadFailed(error.localizedDescription)
}

if bytesRead > 0 {
encoded.append(buffer, count: bytesRead)
} else {
break
}
}

guard encoded.count == length else {
throw BodyPart.Error.unexpectedInputStreamLength(expected: length, bytesRead: encoded.count)
}

return encoded
}

}

extension BodyPart {

enum Error: Swift.Error {
case inputStreamReadFailed(String)
case unexpectedInputStreamLength(expected: Int, bytesRead: Int)
}

}
@@ -0,0 +1,13 @@
import Foundation

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

extension URLRequest {
public mutating func multipartBody(_ body: MultipartFormData) throws {
var multipartEncode = MultipartFormDataEncoder(body: body)
httpBody = try multipartEncode.encode()
setHeaders([.contentType: HTTPContentType.multipart(boundary: body.boundary).value])
}
}
4 changes: 4 additions & 0 deletions Sources/SimpleHTTP/HTTP/HTTPContentType.swift
Expand Up @@ -15,4 +15,8 @@ public struct HTTPContentType: Hashable, ExpressibleByStringLiteral {

extension HTTPContentType {
public static let json: Self = "application/json"
public static let octetStream: Self = "application/octet-stream"
public static func multipart(boundary: String) -> Self {
.init(value: "multipart/form-data; boundary=\(boundary)")
}
}
1 change: 1 addition & 0 deletions Sources/SimpleHTTP/HTTP/HTTPHeader.swift
Expand Up @@ -16,6 +16,7 @@ extension HTTPHeader {
public static let accept: Self = "Accept"
public static let authentication: Self = "Authentication"
public static let contentType: Self = "Content-Type"
public static var contentDisposition: Self = "Content-Disposition"
}

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

0 comments on commit bf380fb

Please sign in to comment.