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 all 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,8 @@
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 @@ -74,6 +76,8 @@
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 @@ -240,6 +244,7 @@
B52674BB2A370D0D006D3B9C /* Upload */ = {
isa = PBXGroup;
children = (
B5A2CE6B2A3FF42400467EB3 /* FormUploadsViewModel.swift */,
B52674BE2A370D33006D3B9C /* UploadItem.swift */,
B52674C62A371046006D3B9C /* UploadItemView.swift */,
B52674C22A370E35006D3B9C /* UploadItemViewModel.swift */,
Expand All @@ -254,6 +259,7 @@
isa = PBXGroup;
children = (
DD6E48752A0E2CD30025AD05 /* DownloadAPIManager+SharedInstance.swift */,
B58162F62A4F23420074A115 /* ByteCountFormatter+Convenience.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -337,9 +343,11 @@
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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

import Foundation
import Networking
import UniformTypeIdentifiers

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

var baseURL: URL {
URL(string: SampleAPIConstants.uploadHost)!
Expand All @@ -23,6 +23,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 All @@ -34,9 +36,3 @@ enum SampleUploadRouter: Requestable {
.post
}
}

private extension URL {
var mimeType: String {
UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream"
}
}
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
cejanen marked this conversation as resolved.
Show resolved Hide resolved
}()
}
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
Expand Up @@ -24,9 +24,9 @@ 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(
id: task.id,
fileName: fileName
Expand All @@ -36,15 +36,29 @@ 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,
fileName: fileUrl.lastPathComponent
)
}

func uploadFormData(_ data: MultipartFormData) async throws -> UploadItem {
let task = try await uploadManager.upload(
multipartFormData: data,
to: SampleUploadRouter.multipart(boundary: data.boundary)
)

let dataSize = Int64(data.size)
let formattedDataSize = ByteCountFormatter.megaBytesFormatter.string(fromByteCount: dataSize)

return UploadItem(
id: task.id,
fileName: "Form upload of size \(formattedDataSize)"
)
}

func uploadStateStream(for uploadTaskId: String) async -> UploadAPIManaging.StateStream {
await uploadManager.stateStream(for: uploadTaskId)
}
Expand All @@ -63,8 +77,7 @@ extension UploadService {

func retry(_ uploadItem: UploadItem) async throws {
try await uploadManager.retry(
taskId: uploadItem.id,
retryConfiguration: .default
taskId: uploadItem.id
)
}
}
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,37 +10,19 @@ import SwiftUI

struct UploadsView: View {
@StateObject var viewModel = UploadsViewModel()
@StateObject 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 @@ -49,7 +31,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 username", text: $formViewModel.username)

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)
}
)
}
}
Loading