Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data and file upload support #43

Merged
merged 55 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
9f2f83b
chore: add helper objects
tonyskansf Jun 12, 2023
885c14d
feat: provide identity to task
tonyskansf Jun 12, 2023
07b9deb
chore: define uploading manager protocol
tonyskansf Jun 12, 2023
729db5a
chore: provide default manager implementation
tonyskansf Jun 12, 2023
379aaa3
feat: provide and update upload task states asynchronously
tonyskansf Jun 12, 2023
da76db3
feat: provide and update upload task state on completion
tonyskansf Jun 12, 2023
5e27b80
chore: add helpful properties on upload task state
tonyskansf Jun 12, 2023
a66f0e9
feat: add upload feature example
tonyskansf Jun 12, 2023
d31f725
feat: allow client to pause/resume/cancel upload tasks
tonyskansf Jun 12, 2023
9d1f6fd
feat: add pause/resume/cancel task example
tonyskansf Jun 12, 2023
c16793a
chore: cleanup code
tonyskansf Jun 12, 2023
a933f1e
feat: add retry behavior
tonyskansf Jun 13, 2023
4d02667
refactor: do not retry on internal errors thrown
tonyskansf Jun 13, 2023
ef2f519
refactor: separate upload request into logical blocks
tonyskansf Jun 13, 2023
74bf2b6
feat: add completed state example
tonyskansf Jun 13, 2023
ce69ec8
docs: add stream completing for non present task
tonyskansf Jun 13, 2023
c63415d
chore: add content disposition http header field
tonyskansf Jun 18, 2023
65dc096
chore: define multiform data objects
tonyskansf Jun 18, 2023
bcc3ad8
feat: allow appending base form data types
tonyskansf Jun 18, 2023
b3920d7
chore: introduce default multi part data encoder
tonyskansf Jun 18, 2023
72836ef
test: add encoder tests
tonyskansf Jun 18, 2023
3d01185
feat: count body part size
tonyskansf Jun 18, 2023
9b2bafb
feat: add multipart/form-data upload support
tonyskansf Jun 18, 2023
e1919db
feat: remove upload task file on complete
tonyskansf Jun 18, 2023
54ef2c2
docs: add additional documentation
tonyskansf Jun 19, 2023
2f85689
feat: add multipart form upload example
tonyskansf Jun 19, 2023
83205be
fix: remove file uploadable conditionally
tonyskansf Jun 19, 2023
f3dc126
fix: cleanup task only on request completion
tonyskansf Jun 19, 2023
1f60d97
refactor: calculate file size from given url
tonyskansf Jun 19, 2023
645166c
refactor: rename MultiFormData to MultipartFormData
tonyskansf Jun 19, 2023
880581e
chore: cleanup code
tonyskansf Jun 19, 2023
44c2998
refactor: simplify call site
tonyskansf Jun 19, 2023
758639f
refactor: rename allTasks to activeTasks
tonyskansf Jun 19, 2023
c27f8d2
Merge remote-tracking branch 'origin/feat/upload' into feat/multipart…
tonyskansf Jun 20, 2023
f5c35ce
feat: show error alerts in examples
tonyskansf Jun 20, 2023
312452c
refactor: provide default inits, make uploadsViewModel state object
tonyskansf Jun 26, 2023
86c96f6
feat: provide task lookup by identifier on `UploadAPIManaging` protocol
tonyskansf Jun 26, 2023
21cb093
refactor: mark extension as public
tonyskansf Jun 26, 2023
6729a56
docs: explain usage of completion closure upload tasks
tonyskansf Jun 26, 2023
e74e2dc
chore: add sample upload server
tonyskansf Jun 30, 2023
c1a58b7
Merge remote-tracking branch 'origin/feat/upload' into feat/multipart…
tonyskansf Jun 30, 2023
30bf7c5
chore: make contentHeaders on body part public
tonyskansf Jun 30, 2023
8b134f7
refactor: log using os_log instead of print
tonyskansf Jun 30, 2023
085c06c
chore: resolve PR comments
tonyskansf Jun 30, 2023
5cd1a72
refactor: use formatter to show file size in MB
tonyskansf Jun 30, 2023
74b2b11
Merge branch 'dev' into feat/upload
gajddo00 Jul 17, 2023
175e9cf
Merge branch 'feat/upload' into feat/multipart-upload
gajddo00 Jul 17, 2023
71eaec8
chore: use delegates instead of completion handler to support backgro…
gajddo00 Aug 7, 2023
78d0b2b
Merge pull request #48 from strvcom/fix/upload-manager-adjustments
cejanen Aug 21, 2023
5836807
[feat] add documentation to data encoder, polish uploadTask
cejanen Aug 21, 2023
c80991b
[feat] adjust upload multipartdata creation flow
cejanen Aug 22, 2023
0f4b9d1
[feat] refactor fileManager on UploadTask
cejanen Aug 23, 2023
e16a558
[feat] remove retry configuration from uploading
cejanen Aug 23, 2023
004dd25
[chore] update boundary prefix
cejanen Sep 6, 2023
70fb5a2
Merge pull request #44 from strvcom/feat/multipart-upload
cejanen Sep 6, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E4E0F029850E86000ACBC0 /* ContentView.swift */; };
58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FB80C6298521FF0031FC59 /* AuthorizationView.swift */; };
58FB80CE29895ABF0031FC59 /* TestData.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 58FB80CD29895ABF0031FC59 /* TestData.xcassets */; };
B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */; };
B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BC2A370D1D006D3B9C /* UploadService.swift */; };
B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674BE2A370D33006D3B9C /* UploadItem.swift */; };
B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */; };
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 */; };
DD6E48762A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */; };
Expand Down Expand Up @@ -60,6 +69,15 @@
58E4E0F029850E86000ACBC0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
58FB80C6298521FF0031FC59 /* AuthorizationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationView.swift; sourceTree = "<group>"; };
58FB80CD29895ABF0031FC59 /* TestData.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = TestData.xcassets; sourceTree = "<group>"; };
B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUploadRouter.swift; sourceTree = "<group>"; };
B52674BC2A370D1D006D3B9C /* UploadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadService.swift; sourceTree = "<group>"; };
B52674BE2A370D33006D3B9C /* UploadItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItem.swift; sourceTree = "<group>"; };
B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsViewModel.swift; sourceTree = "<group>"; };
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemViewModel.swift; sourceTree = "<group>"; };
B52674C42A37102D006D3B9C /* UploadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsView.swift; sourceTree = "<group>"; };
B52674C62A371046006D3B9C /* UploadItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadItemView.swift; sourceTree = "<group>"; };
B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ByteCountFormatter+Convenience.swift"; sourceTree = "<group>"; };
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormUploadsViewModel.swift; sourceTree = "<group>"; };
DD410D6E293F2E6E006D8E31 /* AuthorizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationViewModel.swift; sourceTree = "<group>"; };
DD6E48722A0E24D30025AD05 /* DownloadProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadProgressView.swift; sourceTree = "<group>"; };
DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DownloadAPIManager+SharedInstance.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -126,8 +144,9 @@
23A575ED25F8BF0E00617551 /* Scenes */ = {
isa = PBXGroup;
children = (
58C3E75B29B78ED3004FD1CD /* Download */,
58FB80C5298521DA0031FC59 /* Authorization */,
58C3E75B29B78ED3004FD1CD /* Download */,
B52674BB2A370D0D006D3B9C /* Upload */,
);
path = Scenes;
sourceTree = "<group>";
Expand Down Expand Up @@ -168,6 +187,7 @@
children = (
DDD3AD1E2950E794006CB777 /* SampleAuthRouter.swift */,
58C3E76029B79259004FD1CD /* SampleDownloadRouter.swift */,
B52674B92A370C15006D3B9C /* SampleUploadRouter.swift */,
23EA9CE9292FB70A00B8E418 /* SampleUserRouter.swift */,
);
path = Routers;
Expand Down Expand Up @@ -221,10 +241,25 @@
path = Resources;
sourceTree = "<group>";
};
B52674BB2A370D0D006D3B9C /* Upload */ = {
isa = PBXGroup;
children = (
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */,
B52674BE2A370D33006D3B9C /* UploadItem.swift */,
B52674C62A371046006D3B9C /* UploadItemView.swift */,
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */,
B52674BC2A370D1D006D3B9C /* UploadService.swift */,
B52674C42A37102D006D3B9C /* UploadsView.swift */,
B52674C02A370DFF006D3B9C /* UploadsViewModel.swift */,
);
path = Upload;
sourceTree = "<group>";
};
DD6E48742A0E2CC70025AD05 /* Extensions */ = {
isa = PBXGroup;
children = (
DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */,
B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -308,10 +343,13 @@
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 */,
B58162F72A4F23420074A115 /* ByteCountFormatter+Convenience.swift in Sources */,
58E4E0F129850E86000ACBC0 /* ContentView.swift in Sources */,
B52674BD2A370D1D006D3B9C /* UploadService.swift in Sources */,
58C3E76529B7D709004FD1CD /* DownloadProgressViewModel.swift in Sources */,
23EA9CF9292FB70A00B8E418 /* SampleUserResponse.swift in Sources */,
DDD3AD1F2950E794006CB777 /* SampleAuthRouter.swift in Sources */,
Expand All @@ -320,11 +358,17 @@
23EA9CFA292FB70A00B8E418 /* SampleUserAuthRequest.swift in Sources */,
58C3E76129B79259004FD1CD /* SampleDownloadRouter.swift in Sources */,
23EA9CF4292FB70A00B8E418 /* SampleUserRouter.swift in Sources */,
B52674BF2A370D33006D3B9C /* UploadItem.swift in Sources */,
23EA9CF5292FB70A00B8E418 /* SampleAPIConstants.swift in Sources */,
58FB80C7298521FF0031FC59 /* AuthorizationView.swift in Sources */,
DD410D6F293F2E6E006D8E31 /* AuthorizationViewModel.swift in Sources */,
B52674BA2A370C15006D3B9C /* SampleUploadRouter.swift in Sources */,
B52674C72A371046006D3B9C /* UploadItemView.swift in Sources */,
58C3E75F29B78EE8004FD1CD /* DownloadsViewModel.swift in Sources */,
58C3E75E29B78EE6004FD1CD /* DownloadsView.swift in Sources */,
B52674C32A370E35006D3B9C /* UploadItemViewModel.swift in Sources */,
B52674C12A370DFF006D3B9C /* UploadsViewModel.swift in Sources */,
B52674C52A37102D006D3B9C /* UploadsView.swift in Sources */,
DD6E48732A0E24D30025AD05 /* DownloadProgressView.swift in Sources */,
23EA9CF8292FB70A00B8E418 /* SampleUsersResponse.swift in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SampleUploadRouter.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import Foundation
import Networking

enum SampleUploadRouter: Requestable {
case image
case file(URL)
case multipart(boundary: String)

var baseURL: URL {
URL(string: SampleAPIConstants.uploadHost)!
}

var headers: [String: String]? {
switch self {
case .image:
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)"]
}
}

var path: String {
"/post"
}

var method: HTTPMethod {
.post
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
enum SampleAPIConstants {
static let userHost = "https://reqres.in/api"
static let authHost = "https://nonexistentmockauth.com/api"
static let uploadHost = "https://httpbin.org"
static let validEmail = "eve.holt@reqres.in"
static let validPassword = "cityslicka"
static let videoUrl = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
Expand Down
3 changes: 3 additions & 0 deletions NetworkingSampleApp/NetworkingSampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SwiftUI
enum NetworkingFeature: String, Hashable, CaseIterable {
case authorization
case downloads
case uploads
}

struct ContentView: View {
Expand All @@ -27,6 +28,8 @@ struct ContentView: View {
AuthorizationView()
case .downloads:
DownloadsView()
case .uploads:
UploadsView()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// FormUploadsViewModel.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 19.06.2023.
//

import Foundation
import Networking
import OSLog

@MainActor
final class FormUploadsViewModel: ObservableObject {
@Published var username = ""
@Published var fileUrl: URL?
@Published var isErrorAlertPresented = false
@Published private(set) var error: Error?
@Published private(set) var uploadItemViewModels: [UploadItemViewModel] = []

var selectedFileName: String {
let fileSize = Int64(fileUrl?.fileSize ?? 0)
var fileName = fileUrl?.lastPathComponent ?? ""
let formattedFileSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: fileSize)
if fileSize > 0 { fileName += "\n\(formattedFileSize)" }
return fileName
}

private let uploadService: UploadService

init(uploadService: UploadService = .init()) {
self.uploadService = uploadService
}
}

extension FormUploadsViewModel {
func uploadForm() {
Task {
do {
let multipartFormData = try createMultipartFormData()
let uploadItem = try await uploadService.uploadFormData(multipartFormData)

uploadItemViewModels.append(UploadItemViewModel(
item: uploadItem,
uploadService: uploadService
))

username = ""
fileUrl = nil
} catch {
os_log("❌ FormUploadsViewModel failed to upload form with error: \(error.localizedDescription)")
self.error = error
self.isErrorAlertPresented = true
}
}
}
}

// 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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// UploadItem.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import Foundation

struct UploadItem: Hashable {
let id: String
let fileName: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// UploadItemView.swift
// NetworkingSampleApp
//
// Created by Tony Ngo on 12.06.2023.
//

import SwiftUI

struct UploadItemView: View {
@ObservedObject var viewModel: UploadItemViewModel

var body: some View {
VStack(alignment: .leading) {
HStack {
HStack {
Text(viewModel.fileName)
.font(.subheadline)
Text(viewModel.stateTitle)
.font(.footnote)
.foregroundColor(.gray)
}

Spacer()

if !viewModel.isCancelled && !viewModel.isRetryable && !viewModel.isCompleted {
HStack {
button(
symbol: viewModel.isPaused ? "play" : "pause",
color: .blue,
action: { viewModel.isPaused ? viewModel.resume() : viewModel.pause() }
)

button(
symbol: "x",
color: .red,
action: { viewModel.cancel() }
)
}
} else if viewModel.isRetryable {
button(
symbol: "repeat",
color: .blue,
action: { viewModel.retry() }
)
}
}

if !viewModel.isCancelled && !viewModel.isRetryable {
ProgressView(value: viewModel.progress, total: viewModel.totalProgress)
.progressViewStyle(.linear)
}
}
.animation(.easeOut(duration: 0.3), value: viewModel.progress)
.padding(.vertical, 8)
.task { await viewModel.observeProgress() }
}
}

private extension UploadItemView {
func button(symbol: String, color: Color, action: @escaping () -> Void) -> some View {
Button(
action: action,
label: {
Image(systemName: symbol)
.symbolVariant(.circle.fill)
.font(.title2)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(color)
}
)
.buttonStyle(.plain)
.contentShape(Circle())
}
}
Loading