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

Add multipart/form-data support #44

Merged
merged 30 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
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
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
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
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
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 @@ -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 */; };
Expand Down Expand Up @@ -74,6 +75,7 @@
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>"; };
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 @@ -240,6 +242,7 @@
B52674BB2A370D0D006D3B9C /* Upload */ = {
isa = PBXGroup;
children = (
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */,
B52674BE2A370D33006D3B9C /* UploadItem.swift */,
B52674C62A371046006D3B9C /* UploadItemView.swift */,
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)"]
cejanen marked this conversation as resolved.
Show resolved Hide resolved
}
}
cejanen marked this conversation as resolved.
Show resolved Hide resolved

Expand Down
15 changes: 11 additions & 4 deletions NetworkingSampleApp/NetworkingSampleApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
tonyskansf marked this conversation as resolved.
Show resolved Hide resolved
uploadService: UploadService(
uploadManager: UploadAPIManager()
)
)
))
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 var isErrorAlertPresented = false
@Published private(set) var error: Error?
@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" }
cejanen marked this conversation as resolved.
Show resolved Hide resolved
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 {
try form.append(from: fileUrl, name: "attachment")
}
}

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

text = ""
cejanen marked this conversation as resolved.
Show resolved Hide resolved
fileUrl = nil
} catch {
print("Failed to upload with error:", error)
tonyskansf marked this conversation as resolved.
Show resolved Hide resolved
tonyskansf marked this conversation as resolved.
Show resolved Hide resolved
self.error = error
self.isErrorAlertPresented = true
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ extension UploadService {
)
}

func uploadFormData(_ build: @escaping (MultipartFormData) throws -> Void) async throws -> UploadItem {
cejanen marked this conversation as resolved.
Show resolved Hide resolved
let multipartFormData = MultipartFormData()
try build(multipartFormData)
let task = try await uploadManager.upload(
multipartFormData: multipartFormData,
to: SampleUploadRouter.multipart(boundary: multipartFormData.boundary),
retryConfiguration: .default
)
return UploadItem(
id: task.id,
fileName: "Form upload of size \(multipartFormData.size)"
)
}

func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream {
await uploadManager.stateStream(for: uploadTaskId)
}
Expand Down
133 changes: 110 additions & 23 deletions NetworkingSampleApp/NetworkingSampleApp/Scenes/Upload/UploadsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,18 @@ 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
photo?.loadTransferable(type: Data.self) { result in
viewModel.uploadImage(result: result)
}
}

Button("File") { isFileImporterPresented = true }
.fileImporter(
isPresented: $isFileImporterPresented,
allowedContentTypes: [.mp3, .mpeg4Movie]
) { result in
viewModel.uploadFile(result: result)
}
}
Form {
singleUpload

if !viewModel.uploadItemViewModels.isEmpty {
Section("Upload progress") {
Section("Single upload progress") {
VStack {
ForEach(viewModel.uploadItemViewModels.indices, id: \.self) { index in
let viewModel = viewModel.uploadItemViewModels[index]
Expand All @@ -48,7 +30,112 @@ struct UploadsView: View {
}
}
}

multipartUpload

if !formViewModel.uploadItemViewModels.isEmpty {
Section("Multi part upload progress") {
VStack {
ForEach(formViewModel.uploadItemViewModels.indices, id: \.self) { index in
let viewModel = formViewModel.uploadItemViewModels[index]
UploadItemView(viewModel: viewModel)
}
}
}
}
}
.alert(
tonyskansf marked this conversation as resolved.
Show resolved Hide resolved
"Error",
isPresented: $viewModel.isErrorAlertPresented,
actions: {},
message: {
Text(viewModel.error?.localizedDescription ?? "")
}
)
.alert(
"Error",
isPresented: $formViewModel.isErrorAlertPresented,
actions: {},
message: {
Text(formViewModel.error?.localizedDescription ?? "")
}
)
.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
photo?.loadTransferable(type: Data.self) { result in
viewModel.uploadImage(result: result)
}
}

Button("File") { isFileImporterPresented = true }
.fileImporter(
isPresented: $isFileImporterPresented,
allowedContentTypes: [.mp3, .mpeg4Movie]
) { result in
viewModel.uploadFile(result: result)
}
}
}

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)
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +37,7 @@ extension UploadsViewModel {
} catch {
print("Failed to upload with error", error)
self.error = error
self.isErrorAlertPresented = true
}
}
}
Expand All @@ -52,6 +54,7 @@ extension UploadsViewModel {
} catch {
print("Failed to upload with error", error)
self.error = error
self.isErrorAlertPresented = true
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions Sources/Networking/Core/Upload/MultipartFormData+BodyPart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// MultipartFormData+BodyPart.swift
//
//
// Created by Tony Ngo on 18.06.2023.
//

import Foundation

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.
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 MultipartFormData.BodyPart {
tonyskansf marked this conversation as resolved.
Show resolved Hide resolved
/// 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)\""

if let fileName {
disposition += "; filename=\"\(fileName)\""
}

var headers: [HTTPHeader.HeaderField: String] = [
.contentDisposition: disposition
]

if let mimeType {
headers[.contentType] = mimeType
}

return headers
}
}
Loading