From c63415dcd247500764e1a547ae470627c0fbaac4 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 14:16:13 +0800 Subject: [PATCH 01/26] chore: add content disposition http header field --- Sources/Networking/Misc/HTTPHeader.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/Misc/HTTPHeader.swift b/Sources/Networking/Misc/HTTPHeader.swift index 1272d79e..f46fb2a1 100644 --- a/Sources/Networking/Misc/HTTPHeader.swift +++ b/Sources/Networking/Misc/HTTPHeader.swift @@ -13,8 +13,9 @@ import Foundation public enum HTTPHeader { /// Constants that describe HTTP header keys. public enum HeaderField: String { - case contentType = "Content-Type" case authorization = "Authorization" + case contentDisposition = "Content-Disposition" + case contentType = "Content-Type" } /// Constants that describe values for HTTP header content type keys. From 65dc0967dcbc1e70eb863e2eff5fd1845a474a45 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 14:20:56 +0800 Subject: [PATCH 02/26] chore: define multiform data objects --- .../Core/Upload/MultiFormData+BodyPart.swift | 37 +++++++++++++++++++ .../Core/Upload/MultiFormData.swift | 12 ++++++ 2 files changed, 49 insertions(+) create mode 100644 Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift create mode 100644 Sources/Networking/Core/Upload/MultiFormData.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift new file mode 100644 index 00000000..c8b274d6 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -0,0 +1,37 @@ +// +// MultiFormData+BodyPart.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public extension MultiFormData { + struct BodyPart { + let dataStream: InputStream + let name: String + let fileName: String? + let mimeType: String? + } +} + +extension MultiFormData.BodyPart { + var contentHeaders: [HTTPHeader.HeaderField: String] { + var disposition = "form-data; name=\"\(name)\"" + + if let fileName { + disposition += "; filename=\"\(fileName)\"" + } + + var headers: [HTTPHeader.HeaderField: String] = [ + .contentDisposition: disposition + ] + + if let mimeType { + headers[.contentType] = mimeType + } + + return headers + } +} diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift new file mode 100644 index 00000000..5f3d65c7 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +open class MultiFormData { + +} From bcc3ad8fdfc61f804ccdef108fef2183d1fe1675 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 15:38:01 +0800 Subject: [PATCH 03/26] feat: allow appending base form data types --- .../Core/Upload/MultiFormData.swift | 69 ++++++++++++++++++- .../Networking/Utils/URL+Convenience.swift | 19 +++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Sources/Networking/Utils/URL+Convenience.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 5f3d65c7..fb7e3d4b 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -1,5 +1,5 @@ // -// File.swift +// MultiFormData.swift // // // Created by Tony Ngo on 18.06.2023. @@ -8,5 +8,70 @@ import Foundation open class MultiFormData { - + private(set) var bodyParts: [BodyPart] = [] + let boundary: String + + public init(boundary: String? = nil) { + self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" + } +} + +// MARK: - Adding form data +public extension MultiFormData { + func append( + _ data: Data, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) { + let dataStream = InputStream(data: data) + append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType) + } + + func append( + from fileUrl: URL, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) throws { + let fileName = fileName ?? fileUrl.lastPathComponent + + guard !fileName.isEmpty && !fileUrl.pathExtension.isEmpty else { + throw EncodingError.invalidFileName(at: fileUrl) + } + + guard + !fileUrl.isDirectory && fileUrl.isFileURL, + let dataStream = InputStream(url: fileUrl) + else { + throw EncodingError.invalidFileUrl(fileUrl) + } + + append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType) + } +} + +// MARK: - Private +private extension MultiFormData { + func append( + dataStream: InputStream, + name: String, + fileName: String? = nil, + mimeType: String? = nil + ) { + bodyParts.append(BodyPart( + dataStream: dataStream, + name: name, + fileName: fileName, + mimeType: mimeType + )) + } +} + +// MARK: - Errors +extension MultiFormData { + public enum EncodingError: LocalizedError { + case invalidFileUrl(URL) + case invalidFileName(at: URL) + } } diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift new file mode 100644 index 00000000..a2b8e592 --- /dev/null +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -0,0 +1,19 @@ +// +// URL+Convenience.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation +import UniformTypeIdentifiers + +extension URL { + var mimeType: String { + UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" + } + + var isDirectory: Bool { + (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + } +} From b3920d7e2137d424f4116051de9fd27a7d3eb289 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 17:44:11 +0800 Subject: [PATCH 04/26] chore: introduce default multi part data encoder --- .../Core/Upload/MultiFormData.swift | 4 + .../Core/Upload/MultiFormDataEncoder.swift | 167 ++++++++++++++++++ .../Core/Upload/MultiFormDataEncoding.swift | 13 ++ 3 files changed, 184 insertions(+) create mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoder.swift create mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoding.swift diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index fb7e3d4b..2c151c86 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -73,5 +73,9 @@ extension MultiFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) + case dataStreamReadFailed(with: Error) + case dataStreamWriteFailed(at: URL) + case fileAlreadyExists(at: URL) + } } diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift new file mode 100644 index 00000000..056d85df --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift @@ -0,0 +1,167 @@ +// +// MultiFormDataEncoder.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +open class MultiFormDataEncoder { + private let crlf = "\r\n" + + private let fileManager: FileManager + private let streamBufferSize: Int + + public init( + fileManager: FileManager = .default, + streamBufferSize: Int = 1024 + ) { + self.fileManager = fileManager + self.streamBufferSize = streamBufferSize + } +} + +// MARK: - MultiFormDataEncoding +extension MultiFormDataEncoder: MultiFormDataEncoding { + public func encode(_ multiFormData: MultiFormData) throws -> Data { + var encoded = Data() + + for bodyPart in multiFormData.bodyParts { + encoded.append("\(multiFormData.boundary)\(crlf)") + + let encodedHeaders = encode(bodyPart.contentHeaders) + encoded.append(encodedHeaders) + encoded.append("\(crlf)\(crlf)") + + let encodedData = try encode(bodyPart.dataStream) + encoded.append(encodedData) + encoded.append("\(crlf)") + } + + encoded.append("\(multiFormData.boundary)--\(crlf)") + return encoded + } + + public func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws { + guard fileUrl.isFileURL else { + throw MultiFormData.EncodingError.invalidFileUrl(fileUrl) + } + + guard !fileManager.fileExists(at: fileUrl) else { + throw MultiFormData.EncodingError.fileAlreadyExists(at: fileUrl) + } + + guard let outputStream = OutputStream(url: fileUrl, append: false) else { + throw MultiFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) + } + + try encode(multiFormData, into: outputStream) + } +} + +private extension MultiFormDataEncoder { + func encode(_ multiFormData: MultiFormData, into outputStream: OutputStream) throws { + outputStream.open() + defer { outputStream.close() } + + for bodyPart in multiFormData.bodyParts { + let encodedBoundary = "\(multiFormData.boundary)\(crlf)".data + try write(encodedBoundary, into: outputStream) + + var encodedHeaders = encode(bodyPart.contentHeaders) + encodedHeaders.append("\(crlf)\(crlf)") + try write(encodedHeaders, into: outputStream) + + try write(bodyPart.dataStream, into: outputStream) + try write("\(crlf)".data, into: outputStream) + } + + try write("\(multiFormData.boundary)--\(crlf)".data, into: outputStream) + } + + func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { + let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) + inputStream.open() + defer { + inputStream.close() + buffer.deallocate() + } + + while inputStream.hasBytesAvailable && outputStream.hasSpaceAvailable { + let bytesRead = inputStream.read(buffer, maxLength: streamBufferSize) + + if bytesRead == -1, let error = inputStream.streamError { + throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + } + + if bytesRead > 0 { + outputStream.write(buffer, maxLength: bytesRead) + } + } + } + + func write(_ data: Data, into outputStream: OutputStream) throws { + let inputStream = InputStream(data: data) + try write(inputStream, into: outputStream) + } + + func encode(_ dataStream: InputStream) throws -> Data { + let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) + dataStream.open() + + defer { + dataStream.close() + buffer.deallocate() + } + + var encoded = Data() + while dataStream.hasBytesAvailable { + let bytesRead = dataStream.read(buffer, maxLength: streamBufferSize) + + if bytesRead == -1, let error = dataStream.streamError { + throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + } + + if bytesRead > 0 { + encoded.append(buffer, count: bytesRead) + } + } + return encoded + } + + func encode(_ contentHeaders: [HTTPHeader.HeaderField: String]) -> Data { + var encoded = Data() + + // Encode headers in a deterministic manner for easier testing + let encodedHeaders = contentHeaders + .sorted(by: { $0.key.rawValue < $1.key.rawValue }) + .map { "\($0.key.rawValue): \($0.value)"} + .joined(separator: "\(crlf)") + + encoded.append(encodedHeaders) + return encoded + } +} + +private extension FileManager { + func fileExists(at fileUrl: URL) -> Bool { + if #available(macOS 13.0, iOS 16.0, *) { + return fileExists(atPath: fileUrl.path()) + } else { + return fileExists(atPath: fileUrl.path) + } + } +} + +private extension String { + var data: Data { + Data(self.utf8) + } +} + +private extension Data { + mutating func append(_ string: String) { + self.append(string.data) + } +} diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift new file mode 100644 index 00000000..27c045a2 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift @@ -0,0 +1,13 @@ +// +// MultiFormDataEncoding.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public protocol MultiFormDataEncoding { + func encode(_ multiFormData: MultiFormData) throws -> Data + func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws +} From 72836efbe5f6438bc237dfe737170e6d48d6cd7d Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 18:36:08 +0800 Subject: [PATCH 05/26] test: add encoder tests --- .../MultiFormDataEncoderTests.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 Tests/NetworkingTests/MultiFormDataEncoderTests.swift diff --git a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift b/Tests/NetworkingTests/MultiFormDataEncoderTests.swift new file mode 100644 index 00000000..2b9be5df --- /dev/null +++ b/Tests/NetworkingTests/MultiFormDataEncoderTests.swift @@ -0,0 +1,111 @@ +// +// MultiFormDataEncoderTests.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Networking +import XCTest + +final class MultiFormDataEncoderTests: XCTestCase { + private let fileManager = FileManager.default + + private var temporaryDirectoryUrl: URL { + URL( + fileURLWithPath: NSTemporaryDirectory(), + isDirectory: true + ).appendingPathComponent("multiformdata-encoder-tests") + } + + override func setUpWithError() throws { + try super.setUpWithError() + try fileManager.createDirectory( + atPath: temporaryDirectoryUrl.path, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + try fileManager.removeItem(at: temporaryDirectoryUrl) + } + + func test_encode_encodesDataAsExpected() throws { + let sut = makeSUT() + let formData = MultiFormData(boundary: "--boundary--123") + + let data1 = Data("Hello".utf8) + formData.append(data1, name: "first-data") + + let data2 = Data("World".utf8) + formData.append(data2, name: "second-data", fileName: "file.txt", mimeType: "text/plain") + + let encoded = try sut.encode(formData) + let expectedString = "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"first-data\"\r\n\r\n" + + "Hello\r\n" + + "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"second-data\"; filename=\"file.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "World\r\n" + + "--boundary--123--\r\n" + + XCTAssertEqual(encoded, Data(expectedString.utf8)) + } + + func test_encode_encodesToFileAsExpected() throws { + let sut = makeSUT() + let formData = MultiFormData(boundary: "--boundary--123") + + let data = Data("Hello".utf8) + formData.append(data, name: "first-data") + + let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent(UUID().uuidString) + try sut.encode(formData, to: tmpFileUrl) + + let encoded = try Data(contentsOf: tmpFileUrl) + + let expectedString = "--boundary--123\r\n" + + "Content-Disposition: form-data; name=\"first-data\"\r\n\r\n" + + "Hello\r\n" + + "--boundary--123--\r\n" + + XCTAssertEqual(encoded, Data(expectedString.utf8)) + } + + func test_encode_throwsInvalidFileUrl() { + let sut = makeSUT() + let formData = MultiFormData() + let tmpFileUrl = URL(string: "invalid/path")! + + do { + try sut.encode(formData, to: tmpFileUrl) + XCTFail("Encoding should have failed.") + } catch MultiFormData.EncodingError.invalidFileUrl { + } catch { + XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + } + } + + func test_encode_throwsFileAlreadyExists() { + let sut = makeSUT() + let formData = MultiFormData() + let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent("file") + try? sut.encode(formData, to: tmpFileUrl) + do { + try sut.encode(formData, to: tmpFileUrl) + XCTFail("Encoding should have failed.") + } catch MultiFormData.EncodingError.fileAlreadyExists { + } catch { + XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + } + } +} + +private extension MultiFormDataEncoderTests { + func makeSUT(fileManager: FileManager = .default) -> MultiFormDataEncoder { + let sut = MultiFormDataEncoder(fileManager: fileManager) + return sut + } +} From 3d0118527981e96082580ad8d0a09983eebcc0ff Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:00:23 +0800 Subject: [PATCH 06/26] feat: count body part size --- .../Core/Upload/MultiFormData+BodyPart.swift | 1 + .../Core/Upload/MultiFormData.swift | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift index c8b274d6..3757588d 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -11,6 +11,7 @@ public extension MultiFormData { struct BodyPart { let dataStream: InputStream let name: String + let size: UInt64 let fileName: String? let mimeType: String? } diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 2c151c86..4c86436d 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -8,7 +8,12 @@ import Foundation open class MultiFormData { + public var size: UInt64 { + bodyParts.reduce(0) { $0 + $1.size } + } + private(set) var bodyParts: [BodyPart] = [] + let boundary: String public init(boundary: String? = nil) { @@ -25,12 +30,19 @@ public extension MultiFormData { mimeType: String? = nil ) { let dataStream = InputStream(data: data) - append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType) + append( + dataStream: dataStream, + name: name, + size: UInt64(data.count), + fileName: fileName, + mimeType: mimeType + ) } func append( from fileUrl: URL, name: String, + size: UInt64, fileName: String? = nil, mimeType: String? = nil ) throws { @@ -47,7 +59,13 @@ public extension MultiFormData { throw EncodingError.invalidFileUrl(fileUrl) } - append(dataStream: dataStream, name: name, fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType) + append( + dataStream: dataStream, + name: name, + size: size, + fileName: fileName, + mimeType: mimeType ?? fileUrl.mimeType + ) } } @@ -56,12 +74,14 @@ private extension MultiFormData { func append( dataStream: InputStream, name: String, + size: UInt64, fileName: String? = nil, mimeType: String? = nil ) { bodyParts.append(BodyPart( dataStream: dataStream, name: name, + size: size, fileName: fileName, mimeType: mimeType )) From 9b2bafbe303a5f1d040c0d5c871e4bc64a79674e Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:40:29 +0800 Subject: [PATCH 07/26] feat: add multipart/form-data upload support --- .../Core/Upload/UploadAPIManager.swift | 41 +++++++++++++++++++ .../Core/Upload/UploadAPIManaging.swift | 34 +++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 47e2adb9..d1f66f62 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -31,6 +31,8 @@ open class UploadAPIManager: NSObject { delegateQueue: nil ) + private let multiFormDataEncoder: MultiFormDataEncoding + private let fileManager: FileManager private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] private let errorProcessors: [ErrorProcessing] @@ -40,11 +42,15 @@ open class UploadAPIManager: NSObject { // MARK: - Initialization public init( urlSessionConfiguration: URLSessionConfiguration = .default, + multiFormDataEncoder: MultiFormDataEncoding = MultiFormDataEncoder(), + fileManager: FileManager = .default, requestAdapters: [RequestAdapting] = [], responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], errorProcessors: [ErrorProcessing] = [] ) { self.urlSessionConfiguration = urlSessionConfiguration + self.multiFormDataEncoder = multiFormDataEncoder + self.fileManager = fileManager self.requestAdapters = requestAdapters self.responseProcessors = responseProcessors self.errorProcessors = errorProcessors @@ -106,6 +112,32 @@ extension UploadAPIManager: UploadAPIManaging { ) } + public func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64 = 10_000_000, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask { + let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + + if multiFormData.size < sizeThreshold { + let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) + return try await uploadRequest( + .data(encodedMultiFormData), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) + } else { + let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) + try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) + return try await uploadRequest( + .file(temporaryFileUrl), + request: endpointRequest, + retryConfiguration: retryConfiguration + ) + } + } + public func retry( taskId: String, retryConfiguration: RetryConfiguration? @@ -283,4 +315,13 @@ private extension UploadAPIManager { .values .first { $0.taskIdentifier == task.taskIdentifier } } + + func temporaryFileUrl(for request: EndpointRequest) throws -> URL { + let temporaryFileUrl = fileManager + .temporaryDirectory + .appendingPathComponent("ios-networking") + .appendingPathComponent(request.id) + try fileManager.createDirectory(at: temporaryFileUrl, withIntermediateDirectories: true) + return temporaryFileUrl + } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 75919119..639a7805 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -38,6 +38,24 @@ public protocol UploadAPIManaging { retryConfiguration: RetryConfiguration? ) async throws -> UploadTask + /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. + /// + /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// + /// - Parameters: + /// - multiFormData: The multipart form data to upload. + /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. Defaults to 10MB. + /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. + /// + /// - Returns: An `UploadTask` that represents this request. + func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask + /// Retries the upload task with the specified identifier. /// - Parameters: /// - taskId: The upload task's identifier to retry. @@ -58,3 +76,19 @@ public protocol UploadAPIManaging { /// - Parameter shouldFinishTasks: Determines whether all outstanding tasks should finish before invalidating the session or be immediately cancelled. func invalidateSession(shouldFinishTasks: Bool) } + +extension UploadAPIManaging { + func upload( + multiFormData: MultiFormData, + sizeThreshold: UInt64 = 10_000_000, + to endpoint: Requestable, + retryConfiguration: RetryConfiguration? + ) async throws -> UploadTask { + try await upload( + multiFormData: multiFormData, + sizeThreshold: sizeThreshold, + to: endpoint, + retryConfiguration: retryConfiguration + ) + } +} From e1919dbf7a74a632bb800c91d2c04fff96780807 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Sun, 18 Jun 2023 21:49:24 +0800 Subject: [PATCH 08/26] feat: remove upload task file on complete --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 3 ++- Sources/Networking/Core/Upload/UploadTask.swift | 11 ++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index d1f66f62..9b888e41 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -218,7 +218,8 @@ private extension UploadAPIManager { return UploadTask( sessionUploadTask: sessionUploadTask, endpointRequest: request, - uploadable: uploadable + uploadable: uploadable, + fileManager: fileManager ) } existingUploadTask.task = sessionUploadTask diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 31b01a68..1156cdc4 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -26,6 +26,9 @@ public struct UploadTask { /// The counter that counts number of retries for this task. let retryCounter: Counter + + /// The file manager associated with the task. + let fileManager: FileManager } public extension UploadTask { @@ -79,6 +82,10 @@ extension UploadTask { // cancelling the whole stream before the client can process the emitted value. try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) + + if case let .file(url) = uploadable { + try? fileManager.removeItem(at: url) + } } } @@ -86,13 +93,15 @@ extension UploadTask { init( sessionUploadTask: URLSessionUploadTask, endpointRequest: EndpointRequest, - uploadable: Uploadable + uploadable: Uploadable, + fileManager: FileManager ) { self.task = sessionUploadTask self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) self.retryCounter = Counter() + self.fileManager = fileManager } } From 54ef2c2cbd253d2845241cabde8b6a9826d6e8d8 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 10:16:32 +0800 Subject: [PATCH 09/26] docs: add additional documentation --- .../Core/Upload/MultiFormData+BodyPart.swift | 11 ++++++++ .../Core/Upload/MultiFormData.swift | 28 +++++++++++++++++-- .../Core/Upload/MultiFormDataEncoder.swift | 9 ++++++ .../Core/Upload/MultiFormDataEncoding.swift | 9 ++++++ .../Core/Upload/UploadAPIManager.swift | 2 ++ .../Core/Upload/UploadAPIManaging.swift | 18 ++++++++++-- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift index 3757588d..878eb997 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift @@ -8,16 +8,27 @@ import Foundation public extension MultiFormData { + /// Represents an individual part of the `multipart/form-data`. struct BodyPart { + /// The input stream containing the data of the part's body. let dataStream: InputStream + + /// The name parameter of the `Content-Disposition` header field. let name: String + + /// The size of the part's body. let size: UInt64 + + /// An optional file parameter of the `Content-Disposition` header field. This value may be provided if the body part represents a content of a file. let fileName: String? + + /// An optional value of the `Content-Type` header field. let mimeType: String? } } extension MultiFormData.BodyPart { + /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 4c86436d..91887917 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -7,15 +7,24 @@ import Foundation +/// The `MultiFormData` class provides a convenient way to handle multipart form data. +/// It allows you to construct a multipart form data payload by adding multiple body parts, each representing a separate piece of data. open class MultiFormData { + /// The total size of the `multipart/form-data`. + /// It is calculated as the sum of sizes of all the body parts added to the MultiFormData instance. public var size: UInt64 { bodyParts.reduce(0) { $0 + $1.size } } - private(set) var bodyParts: [BodyPart] = [] + /// Represents the boundary string used to separate the different parts of the multipart form data. + /// It is a unique string that acts as a delimiter between each body part. + public let boundary: String - let boundary: String + private(set) var bodyParts: [BodyPart] = [] + /// Initializes a new instance of `MultiFormData` with an optional boundary string. + /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. + /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" } @@ -23,6 +32,13 @@ open class MultiFormData { // MARK: - Adding form data public extension MultiFormData { + /// Adds a body part to the multipart form data payload using the specified `data`. + /// + /// - Parameters: + /// - data: The data to be added to the payload. + /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. + /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. + /// - mimeType: An optional MIME type of the body part. func append( _ data: Data, name: String, @@ -39,6 +55,14 @@ public extension MultiFormData { ) } + /// Adds a body part to the multipart form data payload using data from a file specified by its URL. + /// + /// - Parameters: + /// - fileUrl: The URL of the file containing the data for the body part. + /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. + /// - size: The size of the body part data. + /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. If not provided, the last path component of the fileUrl is used as the filename (if any). + /// - mimeType: An optional MIME type of the body part. If not provided, the MIME type is inferred from the file extension of the file. func append( from fileUrl: URL, name: String, diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift index 056d85df..b8d0264e 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift @@ -8,11 +8,20 @@ import Foundation open class MultiFormDataEncoder { + /// A string representing a carriage return and line feed. private let crlf = "\r\n" + /// An instance of `FileManager` used to manage files. private let fileManager: FileManager + + /// A read/write stream buffer size in bytes. private let streamBufferSize: Int + /// Creates a `MultiFormDataEncoder` instance with the specified file manager and stream buffer size. + /// + /// - Parameters: + /// - fileManager: A `FileManager` used for files management. + /// - streamBufferSize: A read/write stream buffer size in bytes. Defaults to 1KB. public init( fileManager: FileManager = .default, streamBufferSize: Int = 1024 diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift index 27c045a2..dbae8735 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift +++ b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift @@ -8,6 +8,15 @@ import Foundation public protocol MultiFormDataEncoding { + /// Encodes the specified `MultiFormData` object into a `Data` object. + /// - Parameter multiFormData: The `MultiFormData` object to encode. + /// - Returns: A `Data` object containing the encoded `multiFormData`. func encode(_ multiFormData: MultiFormData) throws -> Data + + /// Encodes the specified `MultiFormData` object and writes it to the specified file URL. + /// + /// - Parameters: + /// - multiFormData: The `MultiFormData` object to encode. + /// - fileUrl: The file URL to write the encoded data to. func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 9b888e41..fc144add 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -120,6 +120,8 @@ extension UploadAPIManager: UploadAPIManaging { ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + // Encode in-memory and upload directly if the payload's size is less than the threshold, + // otherwise we write the payload to the disk first and upload by reading the file content. if multiFormData.size < sizeThreshold { let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) return try await uploadRequest( diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 639a7805..f1555d89 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -42,9 +42,11 @@ public protocol UploadAPIManaging { /// /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. /// + /// When uploaded from disk, a temporary file is created on the file system. This file is deleted when the upload task completes or errors out after all retry attempts. + /// /// - Parameters: /// - multiFormData: The multipart form data to upload. - /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. Defaults to 10MB. + /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// @@ -78,15 +80,25 @@ public protocol UploadAPIManaging { } extension UploadAPIManaging { + /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. + /// + /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// To specify different data threshold, use ``upload(multiFormData:sizeThreshold:to:retryConfiguration:)``. + /// + /// - Parameters: + /// - multiFormData: The multipart form data to upload. + /// - endpoint: The API endpoint to where data will be sent. + /// - retryConfiguration: An optional configuration for retry behavior. + /// + /// - Returns: An `UploadTask` that represents this request. func upload( multiFormData: MultiFormData, - sizeThreshold: UInt64 = 10_000_000, to endpoint: Requestable, retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { try await upload( multiFormData: multiFormData, - sizeThreshold: sizeThreshold, + sizeThreshold: 10_000_000, to: endpoint, retryConfiguration: retryConfiguration ) From 2f85689281447b9d6f06e8e8e50b7367d1203e8b Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 11:16:05 +0800 Subject: [PATCH 10/26] feat: add multipart form upload example --- .../project.pbxproj | 4 + .../API/Routers/SampleUploadRouter.swift | 3 + .../NetworkingSampleApp/ContentView.swift | 15 +- .../Scenes/Upload/FormUploadsViewModel.swift | 59 ++++++++ .../Scenes/Upload/UploadService.swift | 14 ++ .../Scenes/Upload/UploadsView.swift | 133 ++++++++++++++---- .../Core/Upload/UploadAPIManaging.swift | 2 +- 7 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 5eb8bc70..067eb2fb 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; }; B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; }; B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; }; + B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */; }; DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */; }; DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */; }; DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */; }; @@ -74,6 +75,7 @@ B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = ""; }; B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = ""; }; + B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; sourceTree = ""; }; DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationViewModel.swift; sourceTree = ""; }; DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressView.swift; sourceTree = ""; }; DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+SharedInstance.swift"; sourceTree = ""; }; @@ -240,6 +242,7 @@ B52674BB2A370D0D006D3B9C /* Upload */ = { isa = PBXGroup; children = ( + B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */, B52674BE2A370D33006D3B9C /* UploadItem.swift */, B52674C62A371046006D3B9C /* UploadItemView.swift */, B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */, @@ -337,6 +340,7 @@ DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */, DDE8884529476AC300DD3BFF /* SampleRefreshTokenRequest.swift in Sources */, DDD3AD212951F527006CB777 /* SampleAuthorizationManager.swift in Sources */, + B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */, 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index cf907254..e8314fc3 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -12,6 +12,7 @@ import UniformTypeIdentifiers enum SampleUploadRouter: Requestable { case image case file(URL) + case multipart(boundary: String) var baseURL: URL { fatalError("Provide your API base URL for upload") @@ -23,6 +24,8 @@ enum SampleUploadRouter: Requestable { return ["Content-Type": "image/png"] case let .file(url): return ["Content-Type": url.mimeType] + case let .multipart(boundary): + return ["Content-Type": "multipart/form-data; boundary=\(boundary)"] } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift index 6d7d037f..75d1bec4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/ContentView.swift @@ -30,11 +30,18 @@ struct ContentView: View { case .downloads: DownloadsView() case .uploads: - UploadsView(viewModel: UploadsViewModel( - uploadService: UploadService( - uploadManager: UploadAPIManager() + UploadsView( + viewModel: UploadsViewModel( + uploadService: UploadService( + uploadManager: UploadAPIManager() + ) + ), + formViewModel: FormUploadsViewModel( + uploadService: UploadService( + uploadManager: UploadAPIManager() + ) ) - )) + ) } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift new file mode 100644 index 00000000..e13a990c --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -0,0 +1,59 @@ +// +// FormUploadsViewModel.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 19.06.2023. +// + +import Foundation + +@MainActor +final class FormUploadsViewModel: ObservableObject { + @Published var text = "" + @Published var fileUrl: URL? + @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] + + var selectedFileName: String { + let resources = try? fileUrl?.resourceValues(forKeys:[.fileSizeKey]) + let fileSize = (resources?.fileSize ?? 0) / 1_000_000 + var fileName = fileUrl?.lastPathComponent ?? "" + if fileSize > 0 { fileName += "\n\(fileSize) MB" } + return fileName + } + + private let uploadService: UploadService + + init(uploadService: UploadService) { + self.uploadService = uploadService + } +} + +extension FormUploadsViewModel { + func uploadForm() { + Task { + do { + let uploadItem = try await uploadService.uploadFormData { form in + form.append(Data(self.text.utf8), name: "textfield") + + if + let fileUrl = self.fileUrl, + let resources = try? fileUrl.resourceValues(forKeys:[.fileSizeKey]), + let fileSize = resources.fileSize + { + try form.append(from: fileUrl, name: "attachment", size: UInt64(fileSize)) + } + } + + uploadItemViewModels.append(UploadItemViewModel( + item: uploadItem, + uploadService: uploadService + )) + + text = "" + fileUrl = nil + } catch { + print("Failed to upload with error:", error) + } + } + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 48b0a94e..6ba72196 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,6 +45,20 @@ extension UploadService { ) } + func uploadFormData(_ build: @escaping (MultiFormData) throws -> Void) async throws -> UploadItem { + let multiFormData = MultiFormData() + try build(multiFormData) + let task = try await uploadManager.upload( + multiFormData: multiFormData, + to: SampleUploadRouter.multipart(boundary: multiFormData.boundary), + retryConfiguration: .default + ) + return UploadItem( + id: task.id, + fileName: "Form upload of size \(multiFormData.size)" + ) + } + func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { await uploadManager.stateStream(for: uploadTaskId) } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index 4492373a..c6cc8f78 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -10,48 +10,34 @@ import PhotosUI struct UploadsView: View { @ObservedObject var viewModel: UploadsViewModel + @ObservedObject var formViewModel: FormUploadsViewModel @State var isPhotosPickerPresented = false @State var isFileImporterPresented = false + @State var isFormFileImporterPresented = false @State var selectedPhotoPickerItem: PhotosPickerItem? var body: some View { - List { - Section("Upload") { - Button("Photo") { isPhotosPickerPresented = true } - .photosPicker( - isPresented: $isPhotosPickerPresented, - selection: $selectedPhotoPickerItem, - matching: .images - ) - .onChange(of: selectedPhotoPickerItem) { photo in - Task { - if let data = try? await photo?.loadTransferable(type: Data.self) { - await viewModel.uploadImage( - data, - fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension - ) - } - } - } + Form { + singleUpload - Button("File") { isFileImporterPresented = true } - .fileImporter( - isPresented: $isFileImporterPresented, - allowedContentTypes: [.mp3, .mpeg4Movie] - ) { result in - Task { - if let fileUrl = try? result.get() { - await viewModel.uploadFile(at: fileUrl) - } + if !viewModel.uploadItemViewModels.isEmpty { + Section("Single upload progress") { + VStack { + ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in + let viewModel = viewModel.uploadItemViewModels[index] + UploadItemView(viewModel: viewModel) } } + } } - if !viewModel.uploadItemViewModels.isEmpty { - Section("Upload progress") { + multipartUpload + + if !formViewModel.uploadItemViewModels.isEmpty { + Section("Multi part upload progress") { VStack { - ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in - let viewModel = viewModel.uploadItemViewModels[index] + ForEach(formViewModel.uploadItemViewModels.indices, id: \.self) { index in + let viewModel = formViewModel.uploadItemViewModels[index] UploadItemView(viewModel: viewModel) } } @@ -61,3 +47,88 @@ struct UploadsView: View { .navigationTitle("Uploads") } } + +private extension UploadsView { + var singleUpload: some View { + Section("Single") { + Button("Photo") { isPhotosPickerPresented = true } + .photosPicker( + isPresented: $isPhotosPickerPresented, + selection: $selectedPhotoPickerItem, + matching: .images + ) + .onChange(of: selectedPhotoPickerItem) { photo in + Task { + if let data = try? await photo?.loadTransferable(type: Data.self) { + await viewModel.uploadImage( + data, + fileName: selectedPhotoPickerItem?.supportedContentTypes.first?.preferredFilenameExtension + ) + } + } + } + + Button("File") { isFileImporterPresented = true } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.mp3, .mpeg4Movie] + ) { result in + Task { + if let fileUrl = try? result.get() { + await viewModel.uploadFile(at: fileUrl) + } + } + } + } + } + + var multipartUpload: some View { + Section( + content: { + TextField("Enter text", text: $formViewModel.text) + + HStack { + if formViewModel.fileUrl == nil { + Button("Add attachment") { isFormFileImporterPresented = true } + .fileImporter( + isPresented: $isFormFileImporterPresented, + allowedContentTypes: [.mp3, .mpeg4Movie] + ) { result in + formViewModel.fileUrl = try? result.get() + } + } + + + Text(formViewModel.selectedFileName) + + Spacer() + + if formViewModel.fileUrl != nil { + Button( + action: { formViewModel.fileUrl = nil }, + label: { + Image(systemName: "x") + .symbolVariant(.circle.fill) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.tertiary) + } + ) + .buttonStyle(.plain) + .contentShape(Circle()) + } + } + }, + header: { + Text("Multipart") + }, + footer: { + Button("Upload") { + formViewModel.uploadForm() + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + ) + } +} diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index f1555d89..2315d3e7 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -79,7 +79,7 @@ public protocol UploadAPIManaging { func invalidateSession(shouldFinishTasks: Bool) } -extension UploadAPIManaging { +public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. From 83205be38fe17113f0e37d2c292aa4790c835ad2 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 11:46:25 +0800 Subject: [PATCH 11/26] fix: remove file uploadable conditionally --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 10 ++++++---- Sources/Networking/Core/Upload/UploadTask.swift | 2 +- Sources/Networking/Core/Upload/Uploadable.swift | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index fc144add..d28394c5 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -133,7 +133,7 @@ extension UploadAPIManager: UploadAPIManaging { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) return try await uploadRequest( - .file(temporaryFileUrl), + .file(temporaryFileUrl, removeOnComplete: true), request: endpointRequest, retryConfiguration: retryConfiguration ) @@ -297,7 +297,7 @@ private extension UploadAPIManager { from: data, completionHandler: completionHandler ) - case let .file(fileUrl): + case let .file(fileUrl, _): return urlSession.uploadTask( with: request, fromFile: fileUrl, @@ -320,11 +320,13 @@ private extension UploadAPIManager { } func temporaryFileUrl(for request: EndpointRequest) throws -> URL { - let temporaryFileUrl = fileManager + let temporaryDirectoryUrl = fileManager .temporaryDirectory .appendingPathComponent("ios-networking") + + let temporaryFileUrl = temporaryDirectoryUrl .appendingPathComponent(request.id) - try fileManager.createDirectory(at: temporaryFileUrl, withIntermediateDirectories: true) + try fileManager.createDirectory(at: temporaryDirectoryUrl, withIntermediateDirectories: true) return temporaryFileUrl } } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 1156cdc4..88eaaa4c 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -83,7 +83,7 @@ extension UploadTask { try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) - if case let .file(url) = uploadable { + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? fileManager.removeItem(at: url) } } diff --git a/Sources/Networking/Core/Upload/Uploadable.swift b/Sources/Networking/Core/Upload/Uploadable.swift index bec9dc84..290db57e 100644 --- a/Sources/Networking/Core/Upload/Uploadable.swift +++ b/Sources/Networking/Core/Upload/Uploadable.swift @@ -10,5 +10,5 @@ import Foundation /// Represents a data type that can be uploaded. enum Uploadable { case data(Data) - case file(URL) + case file(URL, removeOnComplete: Bool = false) } From f3dc126aa931089c3c0cabc6724ce0af58889256 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 12:16:47 +0800 Subject: [PATCH 12/26] fix: cleanup task only on request completion --- Sources/Networking/Core/Upload/UploadAPIManager.swift | 2 +- Sources/Networking/Core/Upload/UploadTask.swift | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index d28394c5..8e2350c2 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -252,7 +252,7 @@ private extension UploadAPIManager { try await uploadTask.complete(with: state) // Cleanup on successful task completion - await uploadTask.resetRetryCounter() + await uploadTask.cleanup() await uploadTasks.set(value: nil, for: endpointRequest.id) } else if let error { do { diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 88eaaa4c..5e9cb229 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -83,6 +83,11 @@ extension UploadTask { try await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) + } + + func cleanup() async { + await resetRetryCounter() + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? fileManager.removeItem(at: url) } From 1f60d97349853faa2d683c5f5f723513fb705d5c Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:15:59 +0800 Subject: [PATCH 13/26] refactor: calculate file size from given url --- .../Scenes/Upload/FormUploadsViewModel.swift | 8 ++------ Sources/Networking/Core/Upload/MultiFormData.swift | 10 ++++++---- Sources/Networking/Utils/URL+Convenience.swift | 7 +++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index e13a990c..b0d4096d 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -35,12 +35,8 @@ extension FormUploadsViewModel { let uploadItem = try await uploadService.uploadFormData { form in form.append(Data(self.text.utf8), name: "textfield") - if - let fileUrl = self.fileUrl, - let resources = try? fileUrl.resourceValues(forKeys:[.fileSizeKey]), - let fileSize = resources.fileSize - { - try form.append(from: fileUrl, name: "attachment", size: UInt64(fileSize)) + if let fileUrl = self.fileUrl { + try form.append(from: fileUrl, name: "attachment") } } diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultiFormData.swift index 91887917..3fd5ee9e 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultiFormData.swift @@ -60,13 +60,11 @@ public extension MultiFormData { /// - Parameters: /// - fileUrl: The URL of the file containing the data for the body part. /// - name: The name parameter of the `Content-Disposition` header field associated with this body part. - /// - size: The size of the body part data. /// - fileName: An optional filename parameter of the `Content-Disposition` header field associated with this body part. If not provided, the last path component of the fileUrl is used as the filename (if any). /// - mimeType: An optional MIME type of the body part. If not provided, the MIME type is inferred from the file extension of the file. func append( from fileUrl: URL, name: String, - size: UInt64, fileName: String? = nil, mimeType: String? = nil ) throws { @@ -83,10 +81,14 @@ public extension MultiFormData { throw EncodingError.invalidFileUrl(fileUrl) } + guard let fileSize = fileUrl.fileSize else { + throw EncodingError.missingFileSize(for: fileUrl) + } + append( dataStream: dataStream, name: name, - size: size, + size: UInt64(fileSize), fileName: fileName, mimeType: mimeType ?? fileUrl.mimeType ) @@ -117,9 +119,9 @@ extension MultiFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) + case missingFileSize(for: URL) case dataStreamReadFailed(with: Error) case dataStreamWriteFailed(at: URL) case fileAlreadyExists(at: URL) - } } diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift index a2b8e592..21dc5d0c 100644 --- a/Sources/Networking/Utils/URL+Convenience.swift +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -16,4 +16,11 @@ extension URL { var isDirectory: Bool { (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + + var fileSize: Int? { + guard let resources = try? resourceValues(forKeys:[.fileSizeKey]) else { + return nil + } + return resources.fileSize + } } From 645166ca9b7eeae1cb36dc241464a5070e9b096c Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:25:26 +0800 Subject: [PATCH 14/26] refactor: rename MultiFormData to MultipartFormData --- .../Scenes/Upload/UploadService.swift | 12 +++--- .../Core/Upload/MultiFormDataEncoding.swift | 22 ---------- ...swift => MultipartFormData+BodyPart.swift} | 6 +-- ...FormData.swift => MultipartFormData.swift} | 16 +++---- ...r.swift => MultipartFormDataEncoder.swift} | 42 +++++++++---------- .../Upload/MultipartFormDataEncoding.swift | 22 ++++++++++ .../Core/Upload/UploadAPIManager.swift | 16 +++---- .../Core/Upload/UploadAPIManaging.swift | 16 +++---- ...ft => MultipartFormDataEncoderTests.swift} | 28 ++++++------- 9 files changed, 90 insertions(+), 90 deletions(-) delete mode 100644 Sources/Networking/Core/Upload/MultiFormDataEncoding.swift rename Sources/Networking/Core/Upload/{MultiFormData+BodyPart.swift => MultipartFormData+BodyPart.swift} (91%) rename Sources/Networking/Core/Upload/{MultiFormData.swift => MultipartFormData.swift} (91%) rename Sources/Networking/Core/Upload/{MultiFormDataEncoder.swift => MultipartFormDataEncoder.swift} (73%) create mode 100644 Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift rename Tests/NetworkingTests/{MultiFormDataEncoderTests.swift => MultipartFormDataEncoderTests.swift} (75%) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 6ba72196..767ce20f 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,17 +45,17 @@ extension UploadService { ) } - func uploadFormData(_ build: @escaping (MultiFormData) throws -> Void) async throws -> UploadItem { - let multiFormData = MultiFormData() - try build(multiFormData) + func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { + let multipartFormData = MultipartFormData() + try build(multipartFormData) let task = try await uploadManager.upload( - multiFormData: multiFormData, - to: SampleUploadRouter.multipart(boundary: multiFormData.boundary), + multipartFormData: multipartFormData, + to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), retryConfiguration: .default ) return UploadItem( id: task.id, - fileName: "Form upload of size \(multiFormData.size)" + fileName: "Form upload of size \(multipartFormData.size)" ) } diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift deleted file mode 100644 index dbae8735..00000000 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoding.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MultiFormDataEncoding.swift -// -// -// Created by Tony Ngo on 18.06.2023. -// - -import Foundation - -public protocol MultiFormDataEncoding { - /// Encodes the specified `MultiFormData` object into a `Data` object. - /// - Parameter multiFormData: The `MultiFormData` object to encode. - /// - Returns: A `Data` object containing the encoded `multiFormData`. - func encode(_ multiFormData: MultiFormData) throws -> Data - - /// Encodes the specified `MultiFormData` object and writes it to the specified file URL. - /// - /// - Parameters: - /// - multiFormData: The `MultiFormData` object to encode. - /// - fileUrl: The file URL to write the encoded data to. - func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws -} diff --git a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift similarity index 91% rename from Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift rename to Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 878eb997..6208b4e3 100644 --- a/Sources/Networking/Core/Upload/MultiFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift @@ -1,5 +1,5 @@ // -// MultiFormData+BodyPart.swift +// MultipartFormData+BodyPart.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,7 +7,7 @@ import Foundation -public extension MultiFormData { +public extension MultipartFormData { /// Represents an individual part of the `multipart/form-data`. struct BodyPart { /// The input stream containing the data of the part's body. @@ -27,7 +27,7 @@ public extension MultiFormData { } } -extension MultiFormData.BodyPart { +extension MultipartFormData.BodyPart { /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" diff --git a/Sources/Networking/Core/Upload/MultiFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift similarity index 91% rename from Sources/Networking/Core/Upload/MultiFormData.swift rename to Sources/Networking/Core/Upload/MultipartFormData.swift index 3fd5ee9e..b72a11e1 100644 --- a/Sources/Networking/Core/Upload/MultiFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -1,5 +1,5 @@ // -// MultiFormData.swift +// MultipartFormData.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,11 +7,11 @@ import Foundation -/// The `MultiFormData` class provides a convenient way to handle multipart form data. +/// The `MultipartFormData` class provides a convenient way to handle multipart form data. /// It allows you to construct a multipart form data payload by adding multiple body parts, each representing a separate piece of data. -open class MultiFormData { +open class MultipartFormData { /// The total size of the `multipart/form-data`. - /// It is calculated as the sum of sizes of all the body parts added to the MultiFormData instance. + /// It is calculated as the sum of sizes of all the body parts added to the `MultipartFormData` instance. public var size: UInt64 { bodyParts.reduce(0) { $0 + $1.size } } @@ -22,7 +22,7 @@ open class MultiFormData { private(set) var bodyParts: [BodyPart] = [] - /// Initializes a new instance of `MultiFormData` with an optional boundary string. + /// Initializes a new instance of `MultipartFormData` with an optional boundary string. /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { @@ -31,7 +31,7 @@ open class MultiFormData { } // MARK: - Adding form data -public extension MultiFormData { +public extension MultipartFormData { /// Adds a body part to the multipart form data payload using the specified `data`. /// /// - Parameters: @@ -96,7 +96,7 @@ public extension MultiFormData { } // MARK: - Private -private extension MultiFormData { +private extension MultipartFormData { func append( dataStream: InputStream, name: String, @@ -115,7 +115,7 @@ private extension MultiFormData { } // MARK: - Errors -extension MultiFormData { +extension MultipartFormData { public enum EncodingError: LocalizedError { case invalidFileUrl(URL) case invalidFileName(at: URL) diff --git a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift similarity index 73% rename from Sources/Networking/Core/Upload/MultiFormDataEncoder.swift rename to Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index b8d0264e..1d613e8e 100644 --- a/Sources/Networking/Core/Upload/MultiFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -1,5 +1,5 @@ // -// MultiFormDataEncoder.swift +// MultipartFormDataEncoder.swift // // // Created by Tony Ngo on 18.06.2023. @@ -7,7 +7,7 @@ import Foundation -open class MultiFormDataEncoder { +open class MultipartFormDataEncoder { /// A string representing a carriage return and line feed. private let crlf = "\r\n" @@ -17,7 +17,7 @@ open class MultiFormDataEncoder { /// A read/write stream buffer size in bytes. private let streamBufferSize: Int - /// Creates a `MultiFormDataEncoder` instance with the specified file manager and stream buffer size. + /// Creates a `MultipartFormDataEncoder` instance with the specified file manager and stream buffer size. /// /// - Parameters: /// - fileManager: A `FileManager` used for files management. @@ -31,13 +31,13 @@ open class MultiFormDataEncoder { } } -// MARK: - MultiFormDataEncoding -extension MultiFormDataEncoder: MultiFormDataEncoding { - public func encode(_ multiFormData: MultiFormData) throws -> Data { +// MARK: - MultipartFormDataEncoding +extension MultipartFormDataEncoder: MultipartFormDataEncoding { + public func encode(_ multipartFormData: MultipartFormData) throws -> Data { var encoded = Data() - for bodyPart in multiFormData.bodyParts { - encoded.append("\(multiFormData.boundary)\(crlf)") + for bodyPart in multipartFormData.bodyParts { + encoded.append("\(multipartFormData.boundary)\(crlf)") let encodedHeaders = encode(bodyPart.contentHeaders) encoded.append(encodedHeaders) @@ -48,34 +48,34 @@ extension MultiFormDataEncoder: MultiFormDataEncoding { encoded.append("\(crlf)") } - encoded.append("\(multiFormData.boundary)--\(crlf)") + encoded.append("\(multipartFormData.boundary)--\(crlf)") return encoded } - public func encode(_ multiFormData: MultiFormData, to fileUrl: URL) throws { + public func encode(_ multipartFormData: MultipartFormData, to fileUrl: URL) throws { guard fileUrl.isFileURL else { - throw MultiFormData.EncodingError.invalidFileUrl(fileUrl) + throw MultipartFormData.EncodingError.invalidFileUrl(fileUrl) } guard !fileManager.fileExists(at: fileUrl) else { - throw MultiFormData.EncodingError.fileAlreadyExists(at: fileUrl) + throw MultipartFormData.EncodingError.fileAlreadyExists(at: fileUrl) } guard let outputStream = OutputStream(url: fileUrl, append: false) else { - throw MultiFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) + throw MultipartFormData.EncodingError.dataStreamWriteFailed(at: fileUrl) } - try encode(multiFormData, into: outputStream) + try encode(multipartFormData, into: outputStream) } } -private extension MultiFormDataEncoder { - func encode(_ multiFormData: MultiFormData, into outputStream: OutputStream) throws { +private extension MultipartFormDataEncoder { + func encode(_ multipartFormData: MultipartFormData, into outputStream: OutputStream) throws { outputStream.open() defer { outputStream.close() } - for bodyPart in multiFormData.bodyParts { - let encodedBoundary = "\(multiFormData.boundary)\(crlf)".data + for bodyPart in multipartFormData.bodyParts { + let encodedBoundary = "\(multipartFormData.boundary)\(crlf)".data try write(encodedBoundary, into: outputStream) var encodedHeaders = encode(bodyPart.contentHeaders) @@ -86,7 +86,7 @@ private extension MultiFormDataEncoder { try write("\(crlf)".data, into: outputStream) } - try write("\(multiFormData.boundary)--\(crlf)".data, into: outputStream) + try write("\(multipartFormData.boundary)--\(crlf)".data, into: outputStream) } func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { @@ -101,7 +101,7 @@ private extension MultiFormDataEncoder { let bytesRead = inputStream.read(buffer, maxLength: streamBufferSize) if bytesRead == -1, let error = inputStream.streamError { - throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + throw MultipartFormData.EncodingError.dataStreamReadFailed(with: error) } if bytesRead > 0 { @@ -129,7 +129,7 @@ private extension MultiFormDataEncoder { let bytesRead = dataStream.read(buffer, maxLength: streamBufferSize) if bytesRead == -1, let error = dataStream.streamError { - throw MultiFormData.EncodingError.dataStreamReadFailed(with: error) + throw MultipartFormData.EncodingError.dataStreamReadFailed(with: error) } if bytesRead > 0 { diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift new file mode 100644 index 00000000..79e510c9 --- /dev/null +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoding.swift @@ -0,0 +1,22 @@ +// +// MultipartFormDataEncoding.swift +// +// +// Created by Tony Ngo on 18.06.2023. +// + +import Foundation + +public protocol MultipartFormDataEncoding { + /// Encodes the specified `MultipartFormData` object into a `Data` object. + /// - Parameter multipartFormData: The `MultipartFormData` object to encode. + /// - Returns: A `Data` object containing the encoded `multipartFormData`. + func encode(_ multipartFormData: MultipartFormData) throws -> Data + + /// Encodes the specified `MultipartFormData` object and writes it to the specified file URL. + /// + /// - Parameters: + /// - multipartFormData: The `MultipartFormData` object to encode. + /// - fileUrl: The file URL to write the encoded data to. + func encode(_ multipartFormData: MultipartFormData, to fileUrl: URL) throws +} diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 8e2350c2..499b067b 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -31,7 +31,7 @@ open class UploadAPIManager: NSObject { delegateQueue: nil ) - private let multiFormDataEncoder: MultiFormDataEncoding + private let multipartFormDataEncoder: MultipartFormDataEncoding private let fileManager: FileManager private let requestAdapters: [RequestAdapting] private let responseProcessors: [ResponseProcessing] @@ -42,14 +42,14 @@ open class UploadAPIManager: NSObject { // MARK: - Initialization public init( urlSessionConfiguration: URLSessionConfiguration = .default, - multiFormDataEncoder: MultiFormDataEncoding = MultiFormDataEncoder(), + multipartFormDataEncoder: MultipartFormDataEncoding = MultipartFormDataEncoder(), fileManager: FileManager = .default, requestAdapters: [RequestAdapting] = [], responseProcessors: [ResponseProcessing] = [StatusCodeProcessor.shared], errorProcessors: [ErrorProcessing] = [] ) { self.urlSessionConfiguration = urlSessionConfiguration - self.multiFormDataEncoder = multiFormDataEncoder + self.multipartFormDataEncoder = multipartFormDataEncoder self.fileManager = fileManager self.requestAdapters = requestAdapters self.responseProcessors = responseProcessors @@ -113,7 +113,7 @@ extension UploadAPIManager: UploadAPIManaging { } public func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, sizeThreshold: UInt64 = 10_000_000, to endpoint: Requestable, retryConfiguration: RetryConfiguration? @@ -122,16 +122,16 @@ extension UploadAPIManager: UploadAPIManaging { // Encode in-memory and upload directly if the payload's size is less than the threshold, // otherwise we write the payload to the disk first and upload by reading the file content. - if multiFormData.size < sizeThreshold { - let encodedMultiFormData = try multiFormDataEncoder.encode(multiFormData) + if multipartFormData.size < sizeThreshold { + let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( - .data(encodedMultiFormData), + .data(encodedMultipartFormData), request: endpointRequest, retryConfiguration: retryConfiguration ) } else { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) - try multiFormDataEncoder.encode(multiFormData, to: temporaryFileUrl) + try multipartFormDataEncoder.encode(multipartFormData, to: temporaryFileUrl) return try await uploadRequest( .file(temporaryFileUrl, removeOnComplete: true), request: endpointRequest, diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index 2315d3e7..bdc40b3f 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -40,19 +40,19 @@ public protocol UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// - /// If the size of the `MultiFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// If the size of the `MultipartFormData` exceeds the given `sizeThreshold`, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. /// /// When uploaded from disk, a temporary file is created on the file system. This file is deleted when the upload task completes or errors out after all retry attempts. /// /// - Parameters: - /// - multiFormData: The multipart form data to upload. + /// - multipartFormData: The multipart form data to upload. /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, sizeThreshold: UInt64, to endpoint: Requestable, retryConfiguration: RetryConfiguration? @@ -82,22 +82,22 @@ public protocol UploadAPIManaging { public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// - /// If the size of the `MultiFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. - /// To specify different data threshold, use ``upload(multiFormData:sizeThreshold:to:retryConfiguration:)``. + /// If the size of the `MultipartFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. + /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:retryConfiguration:)``. /// /// - Parameters: - /// - multiFormData: The multipart form data to upload. + /// - multipartFormData: The multipart form data to upload. /// - endpoint: The API endpoint to where data will be sent. /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( - multiFormData: MultiFormData, + multipartFormData: MultipartFormData, to endpoint: Requestable, retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { try await upload( - multiFormData: multiFormData, + multipartFormData: multipartFormData, sizeThreshold: 10_000_000, to: endpoint, retryConfiguration: retryConfiguration diff --git a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift similarity index 75% rename from Tests/NetworkingTests/MultiFormDataEncoderTests.swift rename to Tests/NetworkingTests/MultipartFormDataEncoderTests.swift index 2b9be5df..aca40bf7 100644 --- a/Tests/NetworkingTests/MultiFormDataEncoderTests.swift +++ b/Tests/NetworkingTests/MultipartFormDataEncoderTests.swift @@ -1,5 +1,5 @@ // -// MultiFormDataEncoderTests.swift +// MultipartFormDataEncoderTests.swift // // // Created by Tony Ngo on 18.06.2023. @@ -8,14 +8,14 @@ import Networking import XCTest -final class MultiFormDataEncoderTests: XCTestCase { +final class MultipartFormDataEncoderTests: XCTestCase { private let fileManager = FileManager.default private var temporaryDirectoryUrl: URL { URL( fileURLWithPath: NSTemporaryDirectory(), isDirectory: true - ).appendingPathComponent("multiformdata-encoder-tests") + ).appendingPathComponent("multipartformdata-encoder-tests") } override func setUpWithError() throws { @@ -33,7 +33,7 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_encodesDataAsExpected() throws { let sut = makeSUT() - let formData = MultiFormData(boundary: "--boundary--123") + let formData = MultipartFormData(boundary: "--boundary--123") let data1 = Data("Hello".utf8) formData.append(data1, name: "first-data") @@ -56,7 +56,7 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_encodesToFileAsExpected() throws { let sut = makeSUT() - let formData = MultiFormData(boundary: "--boundary--123") + let formData = MultipartFormData(boundary: "--boundary--123") let data = Data("Hello".utf8) formData.append(data, name: "first-data") @@ -76,36 +76,36 @@ final class MultiFormDataEncoderTests: XCTestCase { func test_encode_throwsInvalidFileUrl() { let sut = makeSUT() - let formData = MultiFormData() + let formData = MultipartFormData() let tmpFileUrl = URL(string: "invalid/path")! do { try sut.encode(formData, to: tmpFileUrl) XCTFail("Encoding should have failed.") - } catch MultiFormData.EncodingError.invalidFileUrl { + } catch MultipartFormData.EncodingError.invalidFileUrl { } catch { - XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + XCTFail("Should have failed with MultipartFormData.EncodingError.fileAlreadyExists") } } func test_encode_throwsFileAlreadyExists() { let sut = makeSUT() - let formData = MultiFormData() + let formData = MultipartFormData() let tmpFileUrl = temporaryDirectoryUrl.appendingPathComponent("file") try? sut.encode(formData, to: tmpFileUrl) do { try sut.encode(formData, to: tmpFileUrl) XCTFail("Encoding should have failed.") - } catch MultiFormData.EncodingError.fileAlreadyExists { + } catch MultipartFormData.EncodingError.fileAlreadyExists { } catch { - XCTFail("Should have failed with MultiFormData.EncodingError.fileAlreadyExists") + XCTFail("Should have failed with MultipartFormData.EncodingError.fileAlreadyExists") } } } -private extension MultiFormDataEncoderTests { - func makeSUT(fileManager: FileManager = .default) -> MultiFormDataEncoder { - let sut = MultiFormDataEncoder(fileManager: fileManager) +private extension MultipartFormDataEncoderTests { + func makeSUT(fileManager: FileManager = .default) -> MultipartFormDataEncoder { + let sut = MultipartFormDataEncoder(fileManager: fileManager) return sut } } From 880581e87ab6ef5bb5151e9b1f6c8a3cb172fee9 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Mon, 19 Jun 2023 14:31:35 +0800 Subject: [PATCH 15/26] chore: cleanup code --- .../MultipartFormData+EncodingError.swift | 19 +++++++++++++++++++ .../Core/Upload/MultipartFormData.swift | 12 ------------ .../Upload/MultipartFormDataEncoder.swift | 11 +++++++++-- .../Networking/Core/Upload/UploadTask.swift | 2 ++ 4 files changed, 30 insertions(+), 14 deletions(-) create mode 100644 Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift diff --git a/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift b/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift new file mode 100644 index 00000000..d081c49c --- /dev/null +++ b/Sources/Networking/Core/Upload/MultipartFormData+EncodingError.swift @@ -0,0 +1,19 @@ +// +// MultipartFormData+EncodingError.swift +// +// +// Created by Tony Ngo on 19.06.2023. +// + +import Foundation + +public extension MultipartFormData { + enum EncodingError: LocalizedError { + case invalidFileUrl(URL) + case invalidFileName(at: URL) + case missingFileSize(for: URL) + case dataStreamReadFailed(with: Error) + case dataStreamWriteFailed(at: URL) + case fileAlreadyExists(at: URL) + } +} diff --git a/Sources/Networking/Core/Upload/MultipartFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift index b72a11e1..04e07f1b 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -113,15 +113,3 @@ private extension MultipartFormData { )) } } - -// MARK: - Errors -extension MultipartFormData { - public enum EncodingError: LocalizedError { - case invalidFileUrl(URL) - case invalidFileName(at: URL) - case missingFileSize(for: URL) - case dataStreamReadFailed(with: Error) - case dataStreamWriteFailed(at: URL) - case fileAlreadyExists(at: URL) - } -} diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index 1d613e8e..6337d9c5 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -69,8 +69,12 @@ extension MultipartFormDataEncoder: MultipartFormDataEncoding { } } +// MARK: - Private API private extension MultipartFormDataEncoder { - func encode(_ multipartFormData: MultipartFormData, into outputStream: OutputStream) throws { + func encode( + _ multipartFormData: MultipartFormData, + into outputStream: OutputStream + ) throws { outputStream.open() defer { outputStream.close() } @@ -89,7 +93,10 @@ private extension MultipartFormDataEncoder { try write("\(multipartFormData.boundary)--\(crlf)".data, into: outputStream) } - func write(_ inputStream: InputStream, into outputStream: OutputStream) throws { + func write( + _ inputStream: InputStream, + into outputStream: OutputStream + ) throws { let buffer = UnsafeMutablePointer.allocate(capacity: streamBufferSize) inputStream.open() defer { diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 5e9cb229..51436263 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -31,6 +31,7 @@ public struct UploadTask { let fileManager: FileManager } +// MARK: - Public API public extension UploadTask { /// Resumes the task. /// Has no effect if the task is not in the suspended state. @@ -60,6 +61,7 @@ public extension UploadTask { } } +// MARK: - Internal API extension UploadTask { /// The identifier of the underlying `URLSessionUploadTask`. var taskIdentifier: Int { From f5c35ced0292250d888d771b7afe921f2025d0b8 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Tue, 20 Jun 2023 20:17:29 +0800 Subject: [PATCH 16/26] feat: show error alerts in examples --- .../Scenes/Upload/FormUploadsViewModel.swift | 4 ++++ .../Scenes/Upload/UploadsView.swift | 16 ++++++++++++++++ .../Scenes/Upload/UploadsViewModel.swift | 5 ++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index b0d4096d..a9f40bd5 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -11,6 +11,8 @@ import Foundation final class FormUploadsViewModel: ObservableObject { @Published var text = "" @Published var fileUrl: URL? + @Published var isErrorAlertPresented = false + @Published private(set) var error: Error? @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] var selectedFileName: String { @@ -49,6 +51,8 @@ extension FormUploadsViewModel { fileUrl = nil } catch { print("Failed to upload with error:", error) + self.error = error + self.isErrorAlertPresented = true } } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index d775c5f4..95739b61 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -44,6 +44,22 @@ struct UploadsView: View { } } } + .alert( + "Error", + isPresented: $viewModel.isErrorAlertPresented, + actions: {}, + message: { + Text(viewModel.error?.localizedDescription ?? "") + } + ) + .alert( + "Error", + isPresented: $formViewModel.isErrorAlertPresented, + actions: {}, + message: { + Text(formViewModel.error?.localizedDescription ?? "") + } + ) .navigationTitle("Uploads") } } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index 0a9e6a2b..311304c2 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -9,7 +9,8 @@ import Foundation @MainActor final class UploadsViewModel: ObservableObject { - @Published var error: Error? + @Published var isErrorAlertPresented = false + @Published private(set) var error: Error? @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] private let uploadService: UploadService @@ -36,6 +37,7 @@ extension UploadsViewModel { } catch { print("Failed to upload with error", error) self.error = error + self.isErrorAlertPresented = true } } } @@ -52,6 +54,7 @@ extension UploadsViewModel { } catch { print("Failed to upload with error", error) self.error = error + self.isErrorAlertPresented = true } } } From 30bf7c53c28ccd97e253bf7ceef8f2bd2d0e3e50 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:20:26 +0200 Subject: [PATCH 17/26] chore: make contentHeaders on body part public --- Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift index 6208b4e3..86da8894 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift @@ -27,7 +27,7 @@ public extension MultipartFormData { } } -extension MultipartFormData.BodyPart { +public extension MultipartFormData.BodyPart { /// Returns the body part's header fields and values based on the properties of the instance. var contentHeaders: [HTTPHeader.HeaderField: String] { var disposition = "form-data; name=\"\(name)\"" From 8b134f752666511ace843c8ef17254364b997b33 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:25:57 +0200 Subject: [PATCH 18/26] refactor: log using os_log instead of print --- .../Scenes/Upload/FormUploadsViewModel.swift | 3 ++- .../NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index 49e5df1d..b0cfd8e7 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog @MainActor final class FormUploadsViewModel: ObservableObject { @@ -50,7 +51,7 @@ extension FormUploadsViewModel { text = "" fileUrl = nil } catch { - print("Failed to upload with error:", error) + os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift index be09c11a..a07f2485 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import OSLog @MainActor final class UploadsViewModel: ObservableObject { @@ -35,7 +36,7 @@ extension UploadsViewModel { )) } } catch { - print("Failed to upload with error", error) + os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } @@ -52,7 +53,7 @@ extension UploadsViewModel { uploadService: uploadService )) } catch { - print("Failed to upload with error", error) + os_log("❌ UploadsViewModel failed to upload with error: \(error.localizedDescription)") self.error = error self.isErrorAlertPresented = true } From 085c06c497e84d64eb6a62ca8a1a6d83ed2de410 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:43:43 +0200 Subject: [PATCH 19/26] chore: resolve PR comments --- .../API/Routers/SampleUploadRouter.swift | 7 ------- .../Scenes/Upload/FormUploadsViewModel.swift | 6 +++--- .../NetworkingSampleApp/Scenes/Upload/UploadsView.swift | 2 +- Sources/Networking/Utils/URL+Convenience.swift | 2 +- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift index 15f40a4f..49c2f4b8 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/API/Routers/SampleUploadRouter.swift @@ -7,7 +7,6 @@ import Foundation import Networking -import UniformTypeIdentifiers enum SampleUploadRouter: Requestable { case image @@ -37,9 +36,3 @@ enum SampleUploadRouter: Requestable { .post } } - -private extension URL { - var mimeType: String { - UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" - } -} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index b0cfd8e7..be7b57ea 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -10,7 +10,7 @@ import OSLog @MainActor final class FormUploadsViewModel: ObservableObject { - @Published var text = "" + @Published var username = "" @Published var fileUrl: URL? @Published var isErrorAlertPresented = false @Published private(set) var error: Error? @@ -36,7 +36,7 @@ extension FormUploadsViewModel { Task { do { let uploadItem = try await uploadService.uploadFormData { form in - form.append(Data(self.text.utf8), name: "textfield") + form.append(Data(self.username.utf8), name: "username-textfield") if let fileUrl = self.fileUrl { try form.append(from: fileUrl, name: "attachment") @@ -48,7 +48,7 @@ extension FormUploadsViewModel { uploadService: uploadService )) - text = "" + username = "" fileUrl = nil } catch { os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)") diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift index b25535f8..fb7ae33e 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift @@ -93,7 +93,7 @@ private extension UploadsView { var multipartUpload: some View { Section( content: { - TextField("Enter text", text: $formViewModel.text) + TextField("Enter username", text: $formViewModel.username) HStack { if formViewModel.fileUrl == nil { diff --git a/Sources/Networking/Utils/URL+Convenience.swift b/Sources/Networking/Utils/URL+Convenience.swift index 21dc5d0c..92bf9fbe 100644 --- a/Sources/Networking/Utils/URL+Convenience.swift +++ b/Sources/Networking/Utils/URL+Convenience.swift @@ -8,7 +8,7 @@ import Foundation import UniformTypeIdentifiers -extension URL { +public extension URL { var mimeType: String { UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream" } From 5cd1a725f6b5131c0c0d29f6e295424d5a0b1185 Mon Sep 17 00:00:00 2001 From: Hoang Anh Ngo Date: Fri, 30 Jun 2023 16:56:36 +0200 Subject: [PATCH 20/26] refactor: use formatter to show file size in MB --- .../project.pbxproj | 4 ++++ .../ByteCountFormatter+Convenience.swift | 17 +++++++++++++++++ .../Scenes/Upload/FormUploadsViewModel.swift | 6 +++--- .../Scenes/Upload/UploadService.swift | 7 ++++++- 4 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift diff --git a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj index 067eb2fb..752c9f5b 100644 --- a/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj +++ b/NetworkingSampleApp/NetworkingSampleApp.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */; }; B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C42A37102D006D3B9C /* UploadsView.swift */; }; B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C62A371046006D3B9C /* UploadItemView.swift */; }; + B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */; }; B5A2CE6C2A3FF42400467EB3 /* FormUploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */; }; DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */; }; DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */; }; @@ -75,6 +76,7 @@ B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = ""; }; B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = ""; }; B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = ""; }; + B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ByteCountFormatter+Convenience.swift"; sourceTree = ""; }; B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; sourceTree = ""; }; DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationViewModel.swift; sourceTree = ""; }; DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressView.swift; sourceTree = ""; }; @@ -257,6 +259,7 @@ isa = PBXGroup; children = ( DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */, + B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */, ); path = Extensions; sourceTree = ""; @@ -344,6 +347,7 @@ 23EA9CF7292FB70A00B8E418 /* SampleUserAuthResponse.swift in Sources */, 58E4E0ED2982D884000ACBC0 /* SampleAuthorizationStorageManager.swift in Sources */, 23EA9CF6292FB70A00B8E418 /* SampleAPIError.swift in Sources */, + B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */, 58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */, B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */, 58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */, diff --git a/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift new file mode 100644 index 00000000..f9bde355 --- /dev/null +++ b/NetworkingSampleApp/NetworkingSampleApp/Extensions/ByteCountFormatter+Convenience.swift @@ -0,0 +1,17 @@ +// +// ByteCountFormatter+Convenience.swift +// NetworkingSampleApp +// +// Created by Tony Ngo on 30.06.2023. +// + +import Foundation + +extension ByteCountFormatter { + static let megaBytesFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB] + formatter.countStyle = .file + return formatter + }() +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index be7b57ea..7def0331 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -17,10 +17,10 @@ final class FormUploadsViewModel: ObservableObject { @Published private(set) var uploadItemViewModels: [UploadItemViewModel] = [] var selectedFileName: String { - let resources = try? fileUrl?.resourceValues(forKeys:[.fileSizeKey]) - let fileSize = (resources?.fileSize ?? 0) / 1_000_000 + let fileSize = Int64(fileUrl?.fileSize ?? 0) var fileName = fileUrl?.lastPathComponent ?? "" - if fileSize > 0 { fileName += "\n\(fileSize) MB" } + let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize) + if fileSize > 0 { fileName += "\n\(formattedFileSize)" } return fileName } diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index f949d559..e02e1804 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -48,14 +48,19 @@ extension UploadService { func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { let multipartFormData = MultipartFormData() try build(multipartFormData) + let task = try await uploadManager.upload( multipartFormData: multipartFormData, to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), retryConfiguration: .default ) + + let dataSize = Int64(multipartFormData.size) + let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize) + return UploadItem( id: task.id, - fileName: "Form upload of size \(multipartFormData.size)" + fileName: "Form upload of size \(formattedDataSize)" ) } From 71eaec8ee37ba0d33080dadd39ab8d6c34f3a915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominika=20Gajdov=C3=A1?= <44062027+gajddo00@users.noreply.github.com> Date: Mon, 7 Aug 2023 14:07:53 +0200 Subject: [PATCH 21/26] chore: use delegates instead of completion handler to support background upload, error handling --- .../Core/Upload/UploadAPIManager.swift | 170 ++++++++++-------- .../Networking/Core/Upload/UploadTask.swift | 5 +- 2 files changed, 102 insertions(+), 73 deletions(-) diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index 05a50867..b0c91c0c 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -1,6 +1,6 @@ // // UploadAPIManager.swift -// +// // // Created by Tony Ngo on 12.06.2023. // @@ -59,6 +59,38 @@ open class UploadAPIManager: NSObject { } } +// MARK: URLSessionDataDelegate +extension UploadAPIManager: URLSessionDataDelegate { + public func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + Task { + guard let uploadTask = await uploadTask(for: dataTask) else { + return + } + + if let originalRequest = dataTask.originalRequest, + let response = dataTask.response { + do { + try await handleUploadTaskCompletion( + uploadTask: uploadTask, + urlRequest: originalRequest, + response: response, + data: data + ) + } catch { + await handleUploadTaskError( + uploadTask: uploadTask, + error: error + ) + } + } + } + } +} + // MARK: - URLSessionTaskDelegate extension UploadAPIManager: URLSessionTaskDelegate { public func urlSession( @@ -74,6 +106,27 @@ extension UploadAPIManager: URLSessionTaskDelegate { .send(UploadTask.State(task: task)) } } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + Task { + await uploadTask(for: task)? + .statePublisher + .send(UploadTask.State(task: task)) + + guard let uploadTask = await uploadTask(for: task) else { + return + } + + await handleUploadTaskError( + uploadTask: uploadTask, + error: error + ) + } + } } // MARK: - UploadAPIManaging @@ -119,10 +172,13 @@ extension UploadAPIManager: UploadAPIManaging { retryConfiguration: RetryConfiguration? ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) + + // Determine if the session configuration is background. + let usesBackgroundSession = urlSessionConfiguration.sessionSendsLaunchEvents // Encode in-memory and upload directly if the payload's size is less than the threshold, // otherwise we write the payload to the disk first and upload by reading the file content. - if multipartFormData.size < sizeThreshold { + if multipartFormData.size < sizeThreshold && !usesBackgroundSession { let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( .data(encodedMultipartFormData), @@ -180,30 +236,22 @@ private extension UploadAPIManager { ) async throws -> UploadTask { do { let urlRequest = try await prepare(request) - + let sessionUploadTask = sessionUploadTask( with: uploadable, for: urlRequest - ) { [weak self] data, response, error in - self?.handleUploadTaskCompletion( - urlRequest: urlRequest, - endpointRequest: request, - retryConfiguration: retryConfiguration, - data: data, - response: response, - error: error - ) - } - + ) + let uploadTask = await existingUploadTaskOrNew( for: sessionUploadTask, request: request, uploadable: uploadable ) - + // Store the task for future processing await uploadTasks.set(value: uploadTask, for: request.id) sessionUploadTask.resume() + return uploadTask } catch { throw await errorProcessors.process(error, for: request) @@ -229,59 +277,44 @@ private extension UploadAPIManager { } func handleUploadTaskCompletion( + uploadTask: UploadTask, urlRequest: URLRequest, - endpointRequest: EndpointRequest, - retryConfiguration: RetryConfiguration?, - data: Data?, - response: URLResponse?, + response: URLResponse, + data: Data + ) async throws { + var state = UploadTask.State(task: uploadTask.task) + state.response = try await responseProcessors.process( + (data, response), + with: urlRequest, + for: uploadTask.endpointRequest + ) + await uploadTask.complete(with: state) + + // Cleanup on successful task completion + await uploadTask.resetRetryCounter() + await uploadTasks.set(value: nil, for: uploadTask.endpointRequest.id) + } + + func handleUploadTaskError( + uploadTask: UploadTask, error: Error? - ) { - Task { - guard let uploadTask = await uploadTasks.getValue(for: endpointRequest.id) else { + ) async { + var state = UploadTask.State(task: uploadTask.task) + + if let error { + // URLError.Code.cancelled is thrown if the URLSessionTask is cancelled. + // Consider this action intentional, thus the request won't be retried. + guard !state.cancelled else { return } - - var state = UploadTask.State(task: uploadTask.task) - if let data, let response { - state.response = try await responseProcessors.process( - (data, response), - with: urlRequest, - for: endpointRequest - ) - - try await uploadTask.complete(with: state) - - // Cleanup on successful task completion - await uploadTask.cleanup() - await uploadTasks.set(value: nil, for: endpointRequest.id) - } else if let error { - do { - // URLError.Code.cancelled is thrown if the URLSessionTask is cancelled. - // Consider this action intentional, thus the request won't be retried. - guard !state.cancelled else { - throw error - } - - try await uploadTask.sleepIfRetry( - for: error, - retryConfiguration: retryConfiguration - ) - - try await self.uploadRequest( - uploadTask.uploadable, - request: uploadTask.endpointRequest, - retryConfiguration: retryConfiguration - ) - } catch { - state.error = await errorProcessors.process( - error, - for: uploadTask.endpointRequest - ) - - // No cleanup in case the task will be retried. - try await uploadTask.complete(with: state) - } - } + + state.error = await errorProcessors.process( + error, + for: uploadTask.endpointRequest + ) + + // No cleanup in case the task will be retried. + await uploadTask.complete(with: state) } } @@ -294,21 +327,18 @@ private extension UploadAPIManager { /// - We'll need to handle errors and responses from the request using delegates. func sessionUploadTask( with uploadable: Uploadable, - for request: URLRequest, - completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + for request: URLRequest ) -> URLSessionUploadTask { switch uploadable { case let .data(data): return urlSession.uploadTask( with: request, - from: data, - completionHandler: completionHandler + from: data ) case let .file(fileUrl, _): return urlSession.uploadTask( with: request, - fromFile: fileUrl, - completionHandler: completionHandler + fromFile: fileUrl ) } } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 51436263..a34bc2ea 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -77,14 +77,13 @@ extension UploadTask { /// - Parameters: /// - state: The latest state to emit before completing the task. /// - delay: The delay between the emitting the `state` and completion in nanoseconds. Defaults to 0.2 seconds. - func complete(with state: State, delay: TimeInterval = 20_000_000) async throws { + func complete(with state: State, delay: TimeInterval = 20_000_000) async { statePublisher.send(state) // Publishing value and completion one after another might cause the completion // cancelling the whole stream before the client can process the emitted value. - try await Task.sleep(nanoseconds: UInt64(delay)) + try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) - } func cleanup() async { From 58368078b918598c78c3b126d671021921ab2c72 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Mon, 21 Aug 2023 16:14:28 +0200 Subject: [PATCH 22/26] [feat] add documentation to data encoder, polish uploadTask --- .../Upload/MultipartFormDataEncoder.swift | 6 +++++ .../Core/Upload/UploadAPIManager.swift | 2 +- .../Networking/Core/Upload/UploadTask.swift | 22 +++++++++---------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift index 6337d9c5..a37fecf8 100644 --- a/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift +++ b/Sources/Networking/Core/Upload/MultipartFormDataEncoder.swift @@ -31,6 +31,12 @@ open class MultipartFormDataEncoder { } } + +/** + +The main reason why there are methods to encode data & encode file is similar to `uploadTask(with:from:)` and `uploadTask(with:fromFile:)` ig one could convert the content of the file to Data using Data(contentsOf:) and use the first method to send data. One has the data available in memory while the second reads the data directly from the file thus doesn't load the data into memory so it is more efficient. + */ + // MARK: - MultipartFormDataEncoding extension MultipartFormDataEncoder: MultipartFormDataEncoding { public func encode(_ multipartFormData: MultipartFormData) throws -> Data { diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index b0c91c0c..a9956a9e 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -291,7 +291,7 @@ private extension UploadAPIManager { await uploadTask.complete(with: state) // Cleanup on successful task completion - await uploadTask.resetRetryCounter() + await uploadTask.cleanup() await uploadTasks.set(value: nil, for: uploadTask.endpointRequest.id) } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index a34bc2ea..7257cbc8 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -59,6 +59,14 @@ public extension UploadTask { task.cancel() statePublisher.send(State(task: task)) } + + func cleanup() async { + await resetRetryCounter() + + if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { + try? fileManager.removeItem(at: url) + } + } } // MARK: - Internal API @@ -85,13 +93,9 @@ extension UploadTask { try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) } - - func cleanup() async { - await resetRetryCounter() - - if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { - try? fileManager.removeItem(at: url) - } + + func resetRetryCounter() async { + await retryCounter.reset(for: endpointRequest.id) } } @@ -120,10 +124,6 @@ extension UploadTask: Retryable { retryConfiguration: retryConfiguration ) } - - func resetRetryCounter() async { - await retryCounter.reset(for: endpointRequest.id) - } } // MARK: - Identifiable From c80991bdaf4b13e25e0eb4d3ff24d6ea657b94ac Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Tue, 22 Aug 2023 09:45:35 +0200 Subject: [PATCH 23/26] [feat] adjust upload multipartdata creation flow --- .../Scenes/Upload/FormUploadsViewModel.swift | 22 +++++++++++++------ .../Scenes/Upload/UploadService.swift | 13 +++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift index 7def0331..ebf78050 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/FormUploadsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import Networking import OSLog @MainActor @@ -35,13 +36,8 @@ extension FormUploadsViewModel { func uploadForm() { Task { do { - let uploadItem = try await uploadService.uploadFormData { form in - form.append(Data(self.username.utf8), name: "username-textfield") - - if let fileUrl = self.fileUrl { - try form.append(from: fileUrl, name: "attachment") - } - } + let multipartFormData = try createMultipartFormData() + let uploadItem = try await uploadService.uploadFormData(multipartFormData) uploadItemViewModels.append(UploadItemViewModel( item: uploadItem, @@ -58,3 +54,15 @@ extension FormUploadsViewModel { } } } + +// MARK: - Prepare multipartForm data +private extension FormUploadsViewModel { + func createMultipartFormData() throws -> MultipartFormData { + let multipartFormData = MultipartFormData() + multipartFormData.append(Data(username.utf8), name: "username-textfield") + if let fileUrl { + try multipartFormData.append(from: fileUrl, name: "attachment") + } + return multipartFormData + } +} diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index e02e1804..0227c8d4 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -45,17 +45,14 @@ extension UploadService { ) } - func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem { - let multipartFormData = MultipartFormData() - try build(multipartFormData) - + func uploadFormData(_ data: MultipartFormData) async throws -> UploadItem { let task = try await uploadManager.upload( - multipartFormData: multipartFormData, - to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary), + multipartFormData: data, + to: SampleUploadRouter.multipart(boundary: data.boundary), retryConfiguration: .default ) - let dataSize = Int64(multipartFormData.size) + let dataSize = Int64(data.size) let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize) return UploadItem( @@ -63,7 +60,7 @@ extension UploadService { fileName: "Form upload of size \(formattedDataSize)" ) } - + func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream { await uploadManager.stateStream(for: uploadTaskId) } From 0f4b9d1e48f41349cdbdc48a96377ab7380eb063 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 23 Aug 2023 10:55:57 +0200 Subject: [PATCH 24/26] [feat] refactor fileManager on UploadTask --- .../Scenes/Upload/UploadService.swift | 1 + Sources/Networking/Core/Upload/UploadAPIManager.swift | 3 +-- Sources/Networking/Core/Upload/UploadTask.swift | 9 ++------- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index 0227c8d4..ba85b4de 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -27,6 +27,7 @@ extension UploadService { to: SampleUploadRouter.image, retryConfiguration: .default ) + return UploadItem( id: task.id, fileName: fileName diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index a9956a9e..e3e1b4e2 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -268,8 +268,7 @@ private extension UploadAPIManager { return UploadTask( sessionUploadTask: sessionUploadTask, endpointRequest: request, - uploadable: uploadable, - fileManager: fileManager + uploadable: uploadable ) } existingUploadTask.task = sessionUploadTask diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index 7257cbc8..d667126f 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -26,9 +26,6 @@ public struct UploadTask { /// The counter that counts number of retries for this task. let retryCounter: Counter - - /// The file manager associated with the task. - let fileManager: FileManager } // MARK: - Public API @@ -64,7 +61,7 @@ public extension UploadTask { await resetRetryCounter() if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { - try? fileManager.removeItem(at: url) + try? FileManager.default.removeItem(at: url) } } } @@ -103,15 +100,13 @@ extension UploadTask { init( sessionUploadTask: URLSessionUploadTask, endpointRequest: EndpointRequest, - uploadable: Uploadable, - fileManager: FileManager + uploadable: Uploadable ) { self.task = sessionUploadTask self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) self.retryCounter = Counter() - self.fileManager = fileManager } } From e16a558002ffdf53ebac63f8eafe09b177d68455 Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 23 Aug 2023 12:44:13 +0200 Subject: [PATCH 25/26] [feat] remove retry configuration from uploading --- .../Scenes/Upload/UploadService.swift | 12 +++---- .../Core/Upload/UploadAPIManager.swift | 32 ++++++------------- .../Core/Upload/UploadAPIManaging.swift | 24 ++++---------- .../Networking/Core/Upload/UploadTask.swift | 21 ------------ 4 files changed, 21 insertions(+), 68 deletions(-) diff --git a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift index ba85b4de..9d745649 100644 --- a/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift +++ b/NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadService.swift @@ -24,8 +24,7 @@ extension UploadService { func uploadImage(_ data: Data, fileName: String) async throws -> UploadItem { let task = try await uploadManager.upload( data: data, - to: SampleUploadRouter.image, - retryConfiguration: .default + to: SampleUploadRouter.image ) return UploadItem( @@ -37,8 +36,7 @@ extension UploadService { func uploadFile(_ fileUrl: URL) async throws -> UploadItem { let task = try await uploadManager.upload( fromFile: fileUrl, - to: SampleUploadRouter.file(fileUrl), - retryConfiguration: .default + to: SampleUploadRouter.file(fileUrl) ) return UploadItem( id: task.id, @@ -49,8 +47,7 @@ extension UploadService { func uploadFormData(_ data: MultipartFormData) async throws -> UploadItem { let task = try await uploadManager.upload( multipartFormData: data, - to: SampleUploadRouter.multipart(boundary: data.boundary), - retryConfiguration: .default + to: SampleUploadRouter.multipart(boundary: data.boundary) ) let dataSize = Int64(data.size) @@ -80,8 +77,7 @@ extension UploadService { func retry(_ uploadItem: UploadItem) async throws { try await uploadManager.retry( - taskId: uploadItem.id, - retryConfiguration: .default + taskId: uploadItem.id ) } } diff --git a/Sources/Networking/Core/Upload/UploadAPIManager.swift b/Sources/Networking/Core/Upload/UploadAPIManager.swift index e3e1b4e2..501d85d5 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManager.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManager.swift @@ -141,35 +141,30 @@ extension UploadAPIManager: UploadAPIManaging { public func upload( data: Data, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) return try await uploadRequest( .data(data), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } public func upload( fromFile fileUrl: URL, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) return try await uploadRequest( .file(fileUrl), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } public func upload( multipartFormData: MultipartFormData, sizeThreshold: UInt64 = 10_000_000, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { let endpointRequest = EndpointRequest(endpoint, sessionId: sessionId) @@ -182,24 +177,19 @@ extension UploadAPIManager: UploadAPIManaging { let encodedMultipartFormData = try multipartFormDataEncoder.encode(multipartFormData) return try await uploadRequest( .data(encodedMultipartFormData), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } else { let temporaryFileUrl = try temporaryFileUrl(for: endpointRequest) try multipartFormDataEncoder.encode(multipartFormData, to: temporaryFileUrl) return try await uploadRequest( .file(temporaryFileUrl, removeOnComplete: true), - request: endpointRequest, - retryConfiguration: retryConfiguration + request: endpointRequest ) } } - public func retry( - taskId: String, - retryConfiguration: RetryConfiguration? - ) async throws { + public func retry(taskId: String) async throws { // Get stored upload task to invoke the request with the same arguments guard let existingUploadTask = await uploadTasks.getValue(for: taskId) else { throw NetworkError.unknown @@ -211,8 +201,7 @@ extension UploadAPIManager: UploadAPIManaging { try await uploadRequest( existingUploadTask.uploadable, - request: existingUploadTask.endpointRequest, - retryConfiguration: retryConfiguration + request: existingUploadTask.endpointRequest ) } @@ -231,8 +220,7 @@ private extension UploadAPIManager { @discardableResult func uploadRequest( _ uploadable: Uploadable, - request: EndpointRequest, - retryConfiguration: RetryConfiguration? + request: EndpointRequest ) async throws -> UploadTask { do { let urlRequest = try await prepare(request) diff --git a/Sources/Networking/Core/Upload/UploadAPIManaging.swift b/Sources/Networking/Core/Upload/UploadAPIManaging.swift index b2f8a454..db867d62 100644 --- a/Sources/Networking/Core/Upload/UploadAPIManaging.swift +++ b/Sources/Networking/Core/Upload/UploadAPIManaging.swift @@ -18,24 +18,20 @@ public protocol UploadAPIManaging { /// - Parameters: /// - data: The data to send to the server. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( data: Data, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Initiates a file upload request for the specified endpoint. /// - Parameters: /// - fileUrl: The file's URL to send to the server. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// - Returns: An `UploadTask` that represents this request. func upload( fromFile fileUrl: URL, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. @@ -48,21 +44,18 @@ public protocol UploadAPIManaging { /// - multipartFormData: The multipart form data to upload. /// - sizeThreshold: The size threshold, in bytes, above which the data is streamed from disk rather than being loaded into memory all at once. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( multipartFormData: MultipartFormData, sizeThreshold: UInt64, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask /// Retries the upload task with the specified identifier. /// - Parameters: /// - taskId: The upload task's identifier to retry. - /// - retryConfiguration: An optional configuration for retry behavior. - func retry(taskId: String, retryConfiguration: RetryConfiguration?) async throws + func retry(taskId: String) async throws /// Provides a stream of upload task's states for the specified `UploadTask.ID`. /// @@ -83,24 +76,21 @@ public extension UploadAPIManaging { /// Initiates a `multipart/form-data` upload request to the specified `endpoint`. /// /// If the size of the `MultipartFormData` exceeds 10MB, the data is uploaded from disk rather than being loaded into memory all at once. This can help reduce memory usage when uploading large amounts of data. - /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:retryConfiguration:)``. + /// To specify different data threshold, use ``upload(multipartFormData:sizeThreshold:to:)``. /// /// - Parameters: /// - multipartFormData: The multipart form data to upload. /// - endpoint: The API endpoint to where data will be sent. - /// - retryConfiguration: An optional configuration for retry behavior. /// /// - Returns: An `UploadTask` that represents this request. func upload( multipartFormData: MultipartFormData, - to endpoint: Requestable, - retryConfiguration: RetryConfiguration? + to endpoint: Requestable ) async throws -> UploadTask { try await upload( multipartFormData: multipartFormData, sizeThreshold: 10_000_000, - to: endpoint, - retryConfiguration: retryConfiguration + to: endpoint ) } diff --git a/Sources/Networking/Core/Upload/UploadTask.swift b/Sources/Networking/Core/Upload/UploadTask.swift index d667126f..740694c9 100644 --- a/Sources/Networking/Core/Upload/UploadTask.swift +++ b/Sources/Networking/Core/Upload/UploadTask.swift @@ -23,9 +23,6 @@ public struct UploadTask { /// Use this publisher to emit a new state of the task. let statePublisher: CurrentValueSubject - - /// The counter that counts number of retries for this task. - let retryCounter: Counter } // MARK: - Public API @@ -58,8 +55,6 @@ public extension UploadTask { } func cleanup() async { - await resetRetryCounter() - if case let .file(url, removeOnComplete) = uploadable, removeOnComplete { try? FileManager.default.removeItem(at: url) } @@ -90,10 +85,6 @@ extension UploadTask { try? await Task.sleep(nanoseconds: UInt64(delay)) statePublisher.send(completion: .finished) } - - func resetRetryCounter() async { - await retryCounter.reset(for: endpointRequest.id) - } } extension UploadTask { @@ -106,18 +97,6 @@ extension UploadTask { self.endpointRequest = endpointRequest self.uploadable = uploadable self.statePublisher = .init(State(task: sessionUploadTask)) - self.retryCounter = Counter() - } -} - -// MARK: - Retryable -extension UploadTask: Retryable { - func sleepIfRetry(for error: Error, retryConfiguration: RetryConfiguration?) async throws { - try await sleepIfRetry( - for: error, - endpointRequest: endpointRequest, - retryConfiguration: retryConfiguration - ) } } From 004dd253e4b8a7841a85544052d4fae3d99a80ca Mon Sep 17 00:00:00 2001 From: Tomas Cejka Date: Wed, 6 Sep 2023 12:56:50 +0200 Subject: [PATCH 26/26] [chore] update boundary prefix --- Sources/Networking/Core/Upload/MultipartFormData.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Networking/Core/Upload/MultipartFormData.swift b/Sources/Networking/Core/Upload/MultipartFormData.swift index 04e07f1b..ade36a46 100644 --- a/Sources/Networking/Core/Upload/MultipartFormData.swift +++ b/Sources/Networking/Core/Upload/MultipartFormData.swift @@ -26,7 +26,7 @@ open class MultipartFormData { /// - Parameter boundary: A custom boundary string to be used for separating the body parts in the multipart form data. /// If not provided, a unique boundary string is generated using a combination of "--boundary-" and a UUID. public init(boundary: String? = nil) { - self.boundary = boundary ?? "--boundary-\(UUID().uuidString)" + self.boundary = boundary ?? "----boundary-\(UUID().uuidString)" } }