diff --git a/Package.swift b/Package.swift index 454c2ba..02391ab 100644 --- a/Package.swift +++ b/Package.swift @@ -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") + ] + ), ] ) diff --git a/README.md b/README.md index a37623c..088ae05 100644 --- a/README.md +++ b/README.md @@ -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... diff --git a/Sources/SimpleHTTP/Encoder/MultipartFormDataEncoder.swift b/Sources/SimpleHTTP/Encoder/MultipartFormDataEncoder.swift new file mode 100644 index 0000000..6a55bee --- /dev/null +++ b/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) + } + +} diff --git a/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Multipart.swift b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Multipart.swift new file mode 100644 index 0000000..11b810b --- /dev/null +++ b/Sources/SimpleHTTP/Foundation/URLRequest/URLRequest+Multipart.swift @@ -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]) + } +} diff --git a/Sources/SimpleHTTP/HTTP/HTTPContentType.swift b/Sources/SimpleHTTP/HTTP/HTTPContentType.swift index b443cf6..4d06019 100644 --- a/Sources/SimpleHTTP/HTTP/HTTPContentType.swift +++ b/Sources/SimpleHTTP/HTTP/HTTPContentType.swift @@ -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)") + } } diff --git a/Sources/SimpleHTTP/HTTP/HTTPHeader.swift b/Sources/SimpleHTTP/HTTP/HTTPHeader.swift index 608f790..8a9a63f 100644 --- a/Sources/SimpleHTTP/HTTP/HTTPHeader.swift +++ b/Sources/SimpleHTTP/HTTP/HTTPHeader.swift @@ -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") diff --git a/Sources/SimpleHTTP/Request/MultipartFormData.swift b/Sources/SimpleHTTP/Request/MultipartFormData.swift new file mode 100644 index 0000000..531de97 --- /dev/null +++ b/Sources/SimpleHTTP/Request/MultipartFormData.swift @@ -0,0 +1,206 @@ +import Foundation +import UniformTypeIdentifiers + +#if os(iOS) || os(watchOS) || os(tvOS) +import MobileCoreServices +#elseif os(macOS) +import CoreServices +#endif + +struct Header: Hashable { + + let name: HTTPHeader + let value: String + +} + +enum EncodingCharacters { + static let crlf = "\r\n" +} + +enum Boundary { + + enum `Type` { + case initial + case encapsulated + case final + } + + static func random() -> String { + UUID().uuidString + } + + static func string(for type: Boundary.`Type`, boundary: String) -> String { + switch type { + case .initial: + return "--\(boundary)\(EncodingCharacters.crlf)" + case .encapsulated: + return "\(EncodingCharacters.crlf)--\(boundary)\(EncodingCharacters.crlf)" + case .final: + return "\(EncodingCharacters.crlf)--\(boundary)--\(EncodingCharacters.crlf)" + } + } + + static func data(for type: Boundary.`Type`, boundary: String) -> Data { + let boundaryText = Self.string(for: type, boundary: boundary) + + return Data(boundaryText.utf8) + } +} + +struct BodyPart { + + let headers: [Header] + let stream: InputStream + let length: Int + var hasInitialBoundary = false + var hasFinalBoundary = false + + init(headers: [Header], stream: InputStream, length: Int) { + self.headers = headers + self.stream = stream + self.length = length + } + +} + +/// Constructs `multipart/form-data` for uploads within an HTTP or HTTPS body. +/// We encode the data directly in memory. It's very efficient, but can lead to memory issues if the dataset is too large (eg: a Video) +/// +/// `Warning`: A Second approch to encode bigger dataset will be addes later + +public struct MultipartFormData { + + let boundary: String + let fileManager: FileManager + var bodyParts = [BodyPart]() + + /// Creates an instance + /// + /// - Parameters: + /// - fileManager: `FileManager` to use for file operation, if needed + /// - boundary: `String` used to separate body parts + public init(fileManager: FileManager = .default, boundary: String? = nil) { + self.fileManager = fileManager + self.boundary = boundary ?? Boundary.random() + } + + /// Creates a body part from the file and add it to the instance + /// + /// The body part data will be encode by using this format: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{generated filename}` (HTTPHeader) + /// - `Content-Type: #{generated mimeType}` (HTTPHeader) + /// - Encoded file data + /// - Multipart form boundary + /// + /// The filename in the `Content-Disposition` HTTPHeader is generated from the last path component of the `fileURL`. + /// The `Content-Type` HTTPHeader MIME type is generated by mapping the `fileURL` extension to the system associated MIME type. + /// + /// - Parameters: + /// - url: `URL` of the file to encoding into the instance + /// - name: `String` associated to the `Content-Disposition` HTTPHeader + public mutating func add(url: URL, name: String) throws { + let fileName = url.lastPathComponent + let mimeType = mimeType(from: url) + + try add(url: url, name: name, fileName: fileName, mimeType: mimeType) + } + + /// Creates a body part from the file and add it to the instance + /// + /// The body part data will be encode by using this format: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{generated filename}` (HTTPHeader) + /// - `Content-Type: #{generated mimeType}` (HTTPHeader) + /// - Encoded file data + /// - Multipart form boundary + /// + /// The filename in the `Content-Disposition` HTTPHeader is generated from the last path component of the `fileURL`. + /// The `Content-Type` HTTPHeader MIME type is generated by mapping the `fileURL` extension to the system associated MIME type. + /// + /// - Parameters: + /// - url: `URL` of the file to encoding into the instance + /// - name: `String` associated to the `Content-Disposition` HTTPHeader + /// - fileName: `String` associated to the `Content-Disposition` HTTPHeader + /// - mimeType: `String` associated to the `Content-Type` HTTPHeader + public mutating func add(url: URL, name: String, fileName: String, mimeType: String) throws { + let headers = defineBodyPartHeader(name: name, fileName: fileName, mimeType: mimeType) + + guard let fileSize = try fileManager.attributesOfItem(atPath: url.path)[.size] as? NSNumber else { + throw MultipartFormData.Error.fileSizeNotAvailable(url) + } + + let length = fileSize.intValue + + guard let stream = InputStream(url: url) else { + throw MultipartFormData.Error.inputStreamCreationFailed(url) + } + + bodyParts.append(BodyPart(headers: headers, stream: stream, length: length)) + } + + /// Creates a body part from the data and add it to the instance. + /// + /// The body part data will be encoded by using this: + /// + /// - `Content-Disposition: form-data; name=#{name}; filename=#{filename}` (HTTPHeader) + /// - `Content-Type: #{mimeType}` (HTTPHeader) + /// - Encoded file data + /// - Multipart form boundary + /// + /// - Parameters: + /// - data: `Data` to encoding into the instance. + /// - name: Name associated to the `Data` in the `Content-Disposition` HTTPHeader. + /// - fileName: Filename associated to the `Data` in the `Content-Disposition` HTTPHeader. + /// - mimeType: MIME type associated to the data in the `Content-Type` HTTPHeader. + public mutating func add(data: Data, name: String, fileName: String? = nil, mimeType: String? = nil) { + let headers = defineBodyPartHeader(name: name, fileName: fileName, mimeType: mimeType) + let stream = InputStream(data: data) + let length = data.count + + bodyParts.append(BodyPart(headers: headers, stream: stream, length: length)) + } + + private func defineBodyPartHeader(name: String, fileName: String?, mimeType: String?) -> [Header] { + var headers = [Header]() + var disposition = "form-data; name=\"\(name)\"" + + if let fileName = fileName { + disposition += "; filename=\"\(fileName)\"" + } + + headers.append(Header(name: .contentDisposition, value: disposition)) + + if let mimeType = mimeType { + headers.append(Header(name: .contentType, value: mimeType)) + } + + return headers + } + + private func mimeType(from url: URL) -> String { + if #available(iOS 14.0, *), #available(macOS 11.0, *) { + guard let type = UTType(filenameExtension: url.pathExtension), let mime = type.preferredMIMEType else { + return HTTPContentType.octetStream.value + } + return mime + } else { + if let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, url.pathExtension as CFString, nil)?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue() { + return contentType as String + } + return HTTPContentType.octetStream.value + } + } + +} + +public extension MultipartFormData { + + enum Error: Swift.Error { + case fileSizeNotAvailable(URL) + case inputStreamCreationFailed(URL) + } + +} diff --git a/Sources/SimpleHTTP/Request/Request+URLRequest.swift b/Sources/SimpleHTTP/Request/Request+URLRequest.swift index c72a0bf..1af4191 100644 --- a/Sources/SimpleHTTP/Request/Request+URLRequest.swift +++ b/Sources/SimpleHTTP/Request/Request+URLRequest.swift @@ -27,7 +27,12 @@ extension Request { urlRequest.setHeaders(headers) if let body = body { - try urlRequest.encodeBody(body, encoder: encoder) + switch body { + case .encodable(let body): + try urlRequest.encodeBody(body, encoder: encoder) + case .multipart(let multipart): + try urlRequest.multipartBody(multipart) + } } return urlRequest diff --git a/Sources/SimpleHTTP/Request/Request.swift b/Sources/SimpleHTTP/Request/Request.swift index 17e4112..5f6cde5 100644 --- a/Sources/SimpleHTTP/Request/Request.swift +++ b/Sources/SimpleHTTP/Request/Request.swift @@ -7,6 +7,11 @@ public enum Method: String { case delete } +public enum Body { + case encodable(Encodable) + case multipart(MultipartFormData) +} + /// A Http request expecting an `Output` response /// /// Highly inspired by https://swiftwithmajid.com/2021/02/10/building-type-safe-networking-in-swift/ @@ -15,7 +20,7 @@ public struct Request { /// request relative path public let path: String public let method: Method - public let body: Encodable? + public let body: Body? public let query: [String: QueryParam] public private(set) var headers: HTTPHeaderFields = [:] @@ -23,12 +28,12 @@ public struct Request { self.init(path: path, method: .get, query: query, body: nil) } - public static func post(_ path: Path, body: Encodable?, query: [String: QueryParam] = [:]) + public static func post(_ path: Path, body: Body?, query: [String: QueryParam] = [:]) -> Self { self.init(path: path, method: .post, query: query, body: body) } - public static func put(_ path: Path, body: Encodable, query: [String: QueryParam] = [:]) + public static func put(_ path: Path, body: Body, query: [String: QueryParam] = [:]) -> Self { self.init(path: path, method: .put, query: query, body: body) } @@ -37,7 +42,7 @@ public struct Request { self.init(path: path, method: .delete, query: query, body: nil) } - private init(path: Path, method: Method, query: [String: QueryParam], body: Encodable?) { + private init(path: Path, method: Method, query: [String: QueryParam], body: Body?) { self.path = path.path self.method = method self.body = body diff --git a/Tests/SimpleHTTPTests/Encoder/MultipartFormDataEncoderTests.swift b/Tests/SimpleHTTPTests/Encoder/MultipartFormDataEncoderTests.swift new file mode 100644 index 0000000..309ab28 --- /dev/null +++ b/Tests/SimpleHTTPTests/Encoder/MultipartFormDataEncoderTests.swift @@ -0,0 +1,163 @@ +import XCTest +@testable import SimpleHTTP + +class MultipartFormDataEncoderTests: XCTestCase { + + let crlf = EncodingCharacters.crlf + + func test_encode_multipartAddData_bodyPart() throws { + // Given + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + + let data = "I'm pjechris, Nice to meet you" + let name = "data" + multipart.add(data: Data(data.utf8), name: name) + + let expectedString = ( + Boundary.string(for: .initial, boundary: boundary) + + "Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)" + + data + + Boundary.string(for: .final, boundary: boundary) + ) + let expectedData = Data(expectedString.utf8) + + // When + var encoder = MultipartFormDataEncoder(body: multipart) + let encodedData = try encoder.encode() + + // Then + XCTAssertEqual(encodedData, expectedData) + } + + + func test_encode_data_multipleBodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + + let data1 = "Swift" + let name1 = "swift" + multipart.add(data: Data(data1.utf8), name: name1) + + let data2 = "Combine" + let name2 = "combine" + let mimeType2 = "text/plain" + multipart.add(data: Data(data2.utf8), name: name2, mimeType: mimeType2) + + let expectedString = ( + Boundary.string(for: .initial, boundary: boundary) + + "Content-Disposition: form-data; name=\"\(name1)\"\(crlf)\(crlf)" + + data1 + + Boundary.string(for: .encapsulated, boundary: boundary) + + "Content-Disposition: form-data; name=\"\(name2)\"\(crlf)" + + "Content-Type: \(mimeType2)\(crlf)\(crlf)" + + data2 + + Boundary.string(for: .final, boundary: boundary) + ) + let expectedData = Data(expectedString.utf8) + + var encoder = MultipartFormDataEncoder(body: multipart) + let encodedData = try encoder.encode() + + XCTAssertEqual(encodedData, expectedData) + } + + func test_encode_url_bodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + + let url = URL.Images.swift + let name = "swift" + try multipart.add(url: url, name: name) + + var expectedData = Data() + expectedData.append(Boundary.data(for: .initial, boundary: boundary)) + expectedData.append( + Data(( + "Content-Disposition: form-data; name=\"\(name)\"; filename=\"swift.png\"\(crlf)" + + "Content-Type: image/png\(crlf)\(crlf)" + ).utf8) + ) + expectedData.append(try Data(contentsOf: url)) + expectedData.append(Boundary.data(for: .final, boundary: boundary)) + + var encoder = MultipartFormDataEncoder(body: multipart) + let encodedData = try encoder.encode() + + XCTAssertEqual(encodedData, expectedData) + } + + func test_encode_url_multipleBodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + + let url1 = URL.Images.swift + let name1 = "swift" + try multipart.add(url: url1, name: name1) + + let url2 = URL.Images.swiftUI + let name2 = "swiftUI" + try multipart.add(url: url2, name: name2) + + var expectedData = Data() + expectedData.append(Boundary.data(for: .initial, boundary: boundary)) + expectedData.append(Data(( + "Content-Disposition: form-data; name=\"\(name1)\"; filename=\"swift.png\"\(crlf)" + + "Content-Type: image/png\(crlf)\(crlf)").utf8 + ) + ) + expectedData.append(try Data(contentsOf: url1)) + expectedData.append(Boundary.data(for: .encapsulated, boundary: boundary)) + expectedData.append( + Data(( + "Content-Disposition: form-data; name=\"\(name2)\"; filename=\"swiftUI.png\"\(crlf)" + + "Content-Type: image/png\(crlf)\(crlf)" + ).utf8) + ) + expectedData.append(try Data(contentsOf: url2)) + expectedData.append(Boundary.data(for: .final, boundary: boundary)) + + var encoder = MultipartFormDataEncoder(body: multipart) + let encodedData = try encoder.encode() + + XCTAssertEqual(encodedData, expectedData) + } + + func test_encode_varryingType_multipleBodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + + let data = "I'm pjechris, Nice to meet you" + let name1 = "data" + multipart.add(data: Data(data.utf8), name: name1) + + let url = try url(forResource: "swift", withExtension: "png") + let name2 = "swift" + try multipart.add(url: url, name: name2) + + var expectedData = Data() + expectedData.append(Boundary.data(for: .initial, boundary: boundary)) + expectedData.append( + Data(( + "Content-Disposition: form-data; name=\"\(name1)\"\(crlf)\(crlf)" + + data + ).utf8) + ) + expectedData.append(Boundary.data(for: .encapsulated, boundary: boundary)) + expectedData.append( + Data(( + "Content-Disposition: form-data; name=\"\(name2)\"; filename=\"swift.png\"\(crlf)" + + "Content-Type: image/png\(crlf)\(crlf)" + ).utf8) + ) + expectedData.append(try Data(contentsOf: url)) + expectedData.append(Boundary.data(for: .final, boundary: boundary)) + + var encoder = MultipartFormDataEncoder(body: multipart) + let encodedData = try encoder.encode() + + XCTAssertEqual(encodedData, expectedData) + } + + +} diff --git a/Tests/SimpleHTTPTests/Extensions/XCTestCase+URL.swift b/Tests/SimpleHTTPTests/Extensions/XCTestCase+URL.swift new file mode 100644 index 0000000..40ceac3 --- /dev/null +++ b/Tests/SimpleHTTPTests/Extensions/XCTestCase+URL.swift @@ -0,0 +1,7 @@ +import XCTest + +extension XCTestCase { + func url(forResource fileName: String, withExtension ext: String) throws -> URL { + try XCTUnwrap(Bundle.module.url(forResource: fileName, withExtension: ext)) + } +} diff --git a/Tests/SimpleHTTPTests/Request/MultipartFormDataTest.swift b/Tests/SimpleHTTPTests/Request/MultipartFormDataTest.swift new file mode 100644 index 0000000..930995e --- /dev/null +++ b/Tests/SimpleHTTPTests/Request/MultipartFormDataTest.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import SimpleHTTP + +class MultipartFormDataTest: XCTestCase { + + let crlf = EncodingCharacters.crlf + + func test_addData_withoutFileNameAndMimeType_expectOneBodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + let data = "I'm pjechris, Nice to meet you" + let name = "data" + let expectedHeaders: [Header] = [ + Header(name: .contentDisposition, value: "form-data; name=\"\(name)\"") + ] + + multipart.add(data: Data(data.utf8), name: name) + + XCTAssertEqual(multipart.bodyParts.count, 1) + let bodyPart = try XCTUnwrap(multipart.bodyParts.first) + XCTAssertEqual(bodyPart.headers, expectedHeaders) + } + + func test_addData_oneWithoutFileNameAndMimeType_secondWithAllValue_expect2BodyParts() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + let data1 = "Swift" + let name1 = "swift" + let data2 = "Combine" + let name2 = "combine" + let fileName2 = "combine.txt" + let mimeType2 = "text/plain" + let expectedFirstBodyPartHeaders: [Header] = [ + Header(name: .contentDisposition, value: "form-data; name=\"\(name1)\"") + ] + let expectedLastBodyPartHeaders: [Header] = [ + Header(name: .contentDisposition, value: "form-data; name=\"\(name2)\"; filename=\"\(fileName2)\""), + Header(name: .contentType, value: mimeType2) + ] + + multipart.add(data: Data(data1.utf8), name: name1) + multipart.add(data: Data(data2.utf8), name: name2, fileName: fileName2, mimeType: mimeType2) + + XCTAssertEqual(multipart.bodyParts.count, 2) + let bodyPart1 = try XCTUnwrap(multipart.bodyParts.first) + XCTAssertEqual(bodyPart1.headers, expectedFirstBodyPartHeaders) + let bodyPart2 = try XCTUnwrap(multipart.bodyParts.last) + XCTAssertEqual(bodyPart2.headers, expectedLastBodyPartHeaders) + } + + func test_addURL_bodyPart() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + let url = URL.Images.swift + let name = "swift" + let expectedHeaders: [Header] = [ + Header(name: .contentDisposition, value: "form-data; name=\"\(name)\"; filename=\"swift.png\""), + Header(name: .contentType, value: "image/png") + ] + + try multipart.add(url: url, name: name) + + XCTAssertEqual(multipart.bodyParts.count, 1) + let bodyPart = try XCTUnwrap(multipart.bodyParts.first) + XCTAssertEqual(bodyPart.headers, expectedHeaders) + } + +} diff --git a/Tests/SimpleHTTPTests/Request/RequestTests.swift b/Tests/SimpleHTTPTests/Request/RequestTests.swift index 14f86c2..8d3d111 100644 --- a/Tests/SimpleHTTPTests/Request/RequestTests.swift +++ b/Tests/SimpleHTTPTests/Request/RequestTests.swift @@ -18,26 +18,58 @@ class RequestTests: XCTestCase { XCTAssertEqual(request.httpMethod, "POST") } - - func test_toURLRequest_encodeBody() throws { - let request = try Request.post(TestEndpoint.test, body: Body()) + + func test_toURLRequest_EncodeBody() throws { + let request = try Request.post(TestEndpoint.test, body: .encodable(Body())) .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) - - XCTAssertEqual(request.httpBody, try JSONEncoder().encode(Body())) + + XCTAssertEqual(request.httpBody, try JSONEncoder().encode(Body())) } - - func test_toURLRequest_fillDefaultHeaders() throws { - let request = try Request.post(TestEndpoint.test, body: Body()) + + func test_toURLRequest_encodeMultipartBody() throws { + let crlf = EncodingCharacters.crlf + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + let url = try url(forResource: "swift", withExtension: "png") + let name = "swift" + try multipart.add(url: url, name: name) + + let request = try Request.post(TestEndpoint.test, body: .multipart(multipart)) .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) - - XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + + /// We can't use `XCTAssertEqual(request.httpBody, try multipart.encode)` + /// The `encode` method is executed to fast and rase and error + var body = Data() + body.append(Boundary.data(for: .initial, boundary: boundary)) + body.append( + Data(( + "Content-Disposition: form-data; name=\"\(name)\"; filename=\"swift.png\"\(crlf)" + + "Content-Type: image/png\(crlf)\(crlf)" + ).utf8) + ) + body.append(try Data(contentsOf: url)) + body.append(Boundary.data(for: .final, boundary: boundary)) + XCTAssertEqual(request.httpBody, body) } - - func test_toURLRequest_absoluteStringIsBaseURLPlusPath() throws { - let request = try Request.get(TestEndpoint.test) + + func test_toURLRequest_bodyIsEncodable_FillDefaultHeaders() throws { + let request = try Request.post(TestEndpoint.test, body: .encodable(Body())) .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) - - XCTAssertEqual(request.url?.absoluteString, baseURL.absoluteString + "/test") + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], "application/json") + } + + func test_toURLRequest_bodyIsMultipart_itFillDefaultHeaders() throws { + let boundary = "boundary" + var multipart = MultipartFormData(boundary: boundary) + let url = try url(forResource: "swift", withExtension: "png") + let name = "swift" + try multipart.add(url: url, name: name) + + let request = try Request.post(TestEndpoint.test, body: .multipart(multipart)) + .toURLRequest(encoder: JSONEncoder(), relativeTo: baseURL) + + XCTAssertEqual(request.allHTTPHeaderFields?["Content-Type"], HTTPContentType.multipart(boundary: multipart.boundary).value) } } diff --git a/Tests/SimpleHTTPTests/Ressources/Images/swift.png b/Tests/SimpleHTTPTests/Ressources/Images/swift.png new file mode 100644 index 0000000..72216e8 Binary files /dev/null and b/Tests/SimpleHTTPTests/Ressources/Images/swift.png differ diff --git a/Tests/SimpleHTTPTests/Ressources/Images/swiftUI.png b/Tests/SimpleHTTPTests/Ressources/Images/swiftUI.png new file mode 100644 index 0000000..74f7155 Binary files /dev/null and b/Tests/SimpleHTTPTests/Ressources/Images/swiftUI.png differ diff --git a/Tests/SimpleHTTPTests/Ressources/URL+fromBundle.swift b/Tests/SimpleHTTPTests/Ressources/URL+fromBundle.swift new file mode 100644 index 0000000..40072ef --- /dev/null +++ b/Tests/SimpleHTTPTests/Ressources/URL+fromBundle.swift @@ -0,0 +1,41 @@ +import XCTest + +extension URL { + + static func fromBundle(fileName: String, withExtension ext: String) -> URL? { + Bundle.module.url(forResource: fileName, withExtension: ext) + } + +} + +// MARK - Foundation.Bundle + +#if XCODE_BUILD +extension Foundation.Bundle { + + /// Returns resource bundle as a `Bundle`. + /// Requires Xcode copy phase to locate files into `ExecutableName.bundle`; + /// or `ExecutableNameTests.bundle` for test resources + /// + /// Solution found here + /// - https://stackoverflow.com/questions/47177036/use-resources-in-unit-tests-with-swift-package-manager?answertab=votes#tab-top + static var module: Bundle = { + var thisModuleName = "SimpleHttp" + var url = Bundle.main.bundleURL + + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + url = bundle.bundleURL.deletingLastPathComponent() + thisModuleName = thisModuleName.appending("Tests") + } + + url = url.appendingPathComponent("\(thisModuleName).bundle") + + guard let bundle = Bundle(url: url) else { + fatalError("Foundation.Bundle.module could not load resource bundle: \(url.path)") + } + + return bundle + }() + +} +#endif diff --git a/Tests/SimpleHTTPTests/URL+fixture.swift b/Tests/SimpleHTTPTests/URL+fixture.swift new file mode 100644 index 0000000..c8c7a14 --- /dev/null +++ b/Tests/SimpleHTTPTests/URL+fixture.swift @@ -0,0 +1,12 @@ +import Foundation + +extension URL { + + enum Images { + + static let swift = URL.fromBundle(fileName: "swift", withExtension: "png")! + static let swiftUI = URL.fromBundle(fileName: "swiftUI", withExtension: "png")! + + } + +}