From be07ab923d95cd2c558f180cf85bf798e33bbfef Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki Date: Sun, 9 Jul 2023 23:39:18 +0200 Subject: [PATCH 1/5] Add FileMetadata model --- Sources/DropboxClient/FileMetadata.swift | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Sources/DropboxClient/FileMetadata.swift diff --git a/Sources/DropboxClient/FileMetadata.swift b/Sources/DropboxClient/FileMetadata.swift new file mode 100644 index 0000000..d312ec6 --- /dev/null +++ b/Sources/DropboxClient/FileMetadata.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct FileMetadata: Sendable, Equatable, Identifiable, Decodable { + public init( + id: String, + name: String, + pathDisplay: String, + pathLower: String, + clientModified: Date, + serverModified: Date, + isDownloadable: Bool + ) { + self.id = id + self.name = name + self.pathDisplay = pathDisplay + self.pathLower = pathLower + self.clientModified = clientModified + self.serverModified = serverModified + self.isDownloadable = isDownloadable + } + + public var id: String + public var name: String + public var pathDisplay: String + public var pathLower: String + public var clientModified: Date + public var serverModified: Date + public var isDownloadable: Bool +} From ed18d97a0e2ef5ea0e3d2162828cddd11410306e Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki Date: Sun, 9 Jul 2023 23:53:09 +0200 Subject: [PATCH 2/5] Implement Upload File --- Sources/DropboxClient/UploadFile.swift | 119 ++++++++++++++ .../DropboxClientTests/UploadFileTests.swift | 148 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 Sources/DropboxClient/UploadFile.swift create mode 100644 Tests/DropboxClientTests/UploadFileTests.swift diff --git a/Sources/DropboxClient/UploadFile.swift b/Sources/DropboxClient/UploadFile.swift new file mode 100644 index 0000000..0deacc6 --- /dev/null +++ b/Sources/DropboxClient/UploadFile.swift @@ -0,0 +1,119 @@ +import Foundation + +public struct UploadFile: Sendable { + public struct Params: Sendable, Equatable, Encodable { + public enum Mode: String, Sendable, Equatable, Encodable { + case add, overwrite + } + + public init( + path: String, + data: Data, + mode: Mode? = nil, + autorename: Bool? = nil + ) { + self.path = path + self.data = data + self.mode = mode + self.autorename = autorename + } + + public var path: String + public var data: Data + public var mode: Mode? + public var autorename: Bool? + } + + public enum Error: Swift.Error, Sendable, Equatable { + case notAuthorized + case response(statusCode: Int?, data: Data) + } + + public typealias Run = @Sendable (Params) async throws -> FileMetadata + + public init(run: @escaping Run) { + self.run = run + } + + public var run: Run + + public func callAsFunction(_ params: Params) async throws -> FileMetadata { + try await run(params) + } + + public func callAsFunction( + path: String, + data: Data, + mode: Params.Mode? = nil, + autorename: Bool? = nil + ) async throws -> FileMetadata { + try await run(.init( + path: path, + data: data, + mode: mode, + autorename: autorename + )) + } +} + +extension UploadFile { + public static func live( + keychain: Keychain, + httpClient: HTTPClient + ) -> UploadFile { + UploadFile { params in + guard let credentials = await keychain.loadCredentials() else { + throw Error.notAuthorized + } + + let request: URLRequest = try { + var components = URLComponents() + components.scheme = "https" + components.host = "content.dropboxapi.com" + components.path = "/2/files/upload" + + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + request.setValue( + "\(credentials.tokenType) \(credentials.accessToken)", + forHTTPHeaderField: "Authorization" + ) + + struct Args: Encodable { + var path: String + var mode: String? + var autorename: Bool? + } + let args = Args( + path: params.path, + mode: params.mode?.rawValue, + autorename: params.autorename + ) + let argsData = try JSONEncoder.api.encode(args) + let argsString = String(data: argsData, encoding: .utf8) + + request.setValue( + argsString, + forHTTPHeaderField: "Dropbox-API-Arg" + ) + request.setValue( + "application/octet-stream", + forHTTPHeaderField: "Content-Type" + ) + request.httpBody = params.data + + return request + }() + + let (responseData, response) = try await httpClient.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode + + guard let statusCode, (200..<300).contains(statusCode) else { + throw Error.response(statusCode: statusCode, data: responseData) + } + + let metadata = try JSONDecoder.api.decode(FileMetadata.self, from: responseData) + return metadata + } + } +} diff --git a/Tests/DropboxClientTests/UploadFileTests.swift b/Tests/DropboxClientTests/UploadFileTests.swift new file mode 100644 index 0000000..3fc73ff --- /dev/null +++ b/Tests/DropboxClientTests/UploadFileTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import DropboxClient + +final class UploadFileTests: XCTestCase { + func testUploadFile() async throws { + let params = UploadFile.Params( + path: "/Prime_Numbers.txt", + data: "2,3,5,7...".data(using: .utf8)!, + mode: .add, + autorename: true + ) + let credentials = Credentials.test + let httpRequests = ActorIsolated<[URLRequest]>([]) + let uploadFile = UploadFile.live( + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { credentials } + return keychain + }(), + httpClient: .init { request in + await httpRequests.withValue { $0.append(request) } + return ( + """ + { + "id": "id:a4ayc_80_OEAAAAAAAAAXw", + "name": "Prime_Numbers.txt", + "path_display": "/Homework/math/Prime_Numbers.txt", + "path_lower": "/homework/math/prime_numbers.txt", + "client_modified": "2023-07-06T15:50:38Z", + "server_modified": "2023-07-06T22:10:00Z", + "is_downloadable": true + } + """.data(using: .utf8)!, + HTTPURLResponse( + url: URL(filePath: "/"), + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + ) + + let result = try await uploadFile(params) + + try await httpRequests.withValue { + let url = URL(string: "https://content.dropboxapi.com/2/files/upload")! + var expectedRequest = URLRequest(url: url) + expectedRequest.httpMethod = "POST" + struct Args: Encodable { + var path: String + var mode: String? + var autorename: Bool? + } + expectedRequest.allHTTPHeaderFields = [ + "Authorization": "\(credentials.tokenType) \(credentials.accessToken)", + "Dropbox-API-Arg": String( + data: try JSONEncoder.api.encode(Args( + path: params.path, + mode: params.mode!.rawValue, + autorename: params.autorename! + )), + encoding: .utf8 + )!, + "Content-Type": "application/octet-stream" + ] + expectedRequest.httpBody = params.data + + XCTAssertEqual($0, [expectedRequest]) + XCTAssertEqual($0.first?.httpBody, expectedRequest.httpBody!) + } + XCTAssertEqual(result, FileMetadata( + id: "id:a4ayc_80_OEAAAAAAAAAXw", + name: "Prime_Numbers.txt", + pathDisplay: "/Homework/math/Prime_Numbers.txt", + pathLower: "/homework/math/prime_numbers.txt", + clientModified: Calendar(identifier: .gregorian) + .date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2023, month: 7, day: 6, + hour: 15, minute: 50, second: 38 + ))!, + serverModified: Calendar(identifier: .gregorian) + .date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2023, month: 7, day: 6, + hour: 22, minute: 10 + ))!, + isDownloadable: true + )) + } + + func testUploadFileErrorResponse() async { + let uploadFile = UploadFile.live( + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { .test } + return keychain + }(), + httpClient: .init { request in + ( + "Error!!!".data(using: .utf8)!, + HTTPURLResponse( + url: URL(filePath: "/"), + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + ) + } + ) + + do { + _ = try await uploadFile(path: "/test.txt", data: Data()) + XCTFail("Expected to throw, but didn't") + } catch { + XCTAssertEqual( + error as? UploadFile.Error, + .response( + statusCode: 500, + data: "Error!!!".data(using: .utf8)! + ), + "Expected to throw response error, got \(error)" + ) + } + } + + func testUploadFileWhenNotAuthorized() async { + let uploadFile = UploadFile.live( + keychain: { + var keychain = Keychain.unimplemented() + keychain.loadCredentials = { nil } + return keychain + }(), + httpClient: .unimplemented() + ) + + do { + _ = try await uploadFile(path: "/test.txt", data: Data()) + XCTFail("Expected to throw, but didn't") + } catch { + XCTAssertEqual( + error as? UploadFile.Error, .notAuthorized, + "Expected to throw .notAuthorized, got \(error)" + ) + } + } +} From 17004aa1e9a91c00cae0056b20c885a0558eafa6 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki Date: Sun, 9 Jul 2023 23:53:18 +0200 Subject: [PATCH 3/5] Update Client --- Sources/DropboxClient/Client.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/DropboxClient/Client.swift b/Sources/DropboxClient/Client.swift index 3672fdb..259fd55 100644 --- a/Sources/DropboxClient/Client.swift +++ b/Sources/DropboxClient/Client.swift @@ -3,18 +3,21 @@ public struct Client: Sendable { auth: Auth, listFolder: ListFolder, downloadFile: DownloadFile, - deleteFile: DeleteFile + deleteFile: DeleteFile, + uploadFile: UploadFile ) { self.auth = auth self.listFolder = listFolder self.downloadFile = downloadFile self.deleteFile = deleteFile + self.uploadFile = uploadFile } public var auth: Auth public var listFolder: ListFolder public var downloadFile: DownloadFile public var deleteFile: DeleteFile + public var uploadFile: UploadFile } extension Client { @@ -46,6 +49,10 @@ extension Client { deleteFile: .live( keychain: keychain, httpClient: httpClient + ), + uploadFile: .live( + keychain: keychain, + httpClient: httpClient ) ) } From 85b97eb4863a8eb92319a0126454ed97033edab3 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki Date: Sun, 9 Jul 2023 23:54:20 +0200 Subject: [PATCH 4/5] Update example --- .../Dependencies.swift | 11 +++++ .../DropboxClientExampleApp/ExampleView.swift | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Example/DropboxClientExampleApp/Dependencies.swift b/Example/DropboxClientExampleApp/Dependencies.swift index b8a931c..2c86f84 100644 --- a/Example/DropboxClientExampleApp/Dependencies.swift +++ b/Example/DropboxClientExampleApp/Dependencies.swift @@ -71,6 +71,17 @@ extension DropboxClient.Client: DependencyKey { return entry } return DeleteFile.Result(metadata: entry) + }, + uploadFile: .init { params in + FileMetadata( + id: "id:preview-uploaded", + name: "Preview-uploaded.txt", + pathDisplay: "/Preview-uploaded.txt", + pathLower: "/preview-uploaded.txt", + clientModified: Date(), + serverModified: Date(), + isDownloadable: true + ) } ) }() diff --git a/Example/DropboxClientExampleApp/ExampleView.swift b/Example/DropboxClientExampleApp/ExampleView.swift index 0224d11..b6be53e 100644 --- a/Example/DropboxClientExampleApp/ExampleView.swift +++ b/Example/DropboxClientExampleApp/ExampleView.swift @@ -93,6 +93,26 @@ struct ExampleView: View { } label: { Text("List Folder") } + + Button { + Task { + do { + _ = try await client.uploadFile( + path: "/example-upload.txt", + data: "swift-dropbox-client example uploaded file".data(using: .utf8)!, + mode: .add, + autorename: true + ) + } catch { + log.error("UploadFile failure", metadata: [ + "error": "\(error)", + "localizedDescription": "\(error.localizedDescription)" + ]) + } + } + } label: { + Text("Upload file") + } } if let list { @@ -165,6 +185,27 @@ struct ExampleView: View { Text("Download File") } + Button { + Task { + do { + _ = try await client.uploadFile( + path: entry.pathDisplay, + data: "Uploaded with overwrite at \(Date().formatted(date: .complete, time: .complete))" + .data(using: .utf8)!, + mode: .overwrite, + autorename: false + ) + } catch { + log.error("UploadFile failure", metadata: [ + "error": "\(error)", + "localizedDescription": "\(error.localizedDescription)" + ]) + } + } + } label: { + Text("Upload file (overwrite)") + } + Button(role: .destructive) { Task { do { From 1eff35a9b4eae92d6d43dc28da2ff262798b6f32 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki Date: Sun, 9 Jul 2023 23:56:04 +0200 Subject: [PATCH 5/5] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 322697e..b88bb17 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Basic Dropbox HTTP API client that does not depend on Dropbox's SDK. No external - Authorize access - List folder +- Upload file - Download file -- ... +- Delete file ## 📖 Usage