Skip to content

Commit e02d444

Browse files
committed
Implement folder listing
1 parent 0edded2 commit e02d444

File tree

8 files changed

+230
-24
lines changed

8 files changed

+230
-24
lines changed

Example/DropboxClientExampleApp/ExampleView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ struct ExampleView: View {
6767
Button {
6868
Task<Void, Never> {
6969
do {
70-
list = try await client.listFolder(.init())
70+
list = try await client.listFolder(path: "")
7171
} catch {
7272
log.error("ListFolder failure", metadata: [
7373
"error": "\(error)",

Sources/DropboxClient/Auth.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ extension Auth {
161161
var accountId: String
162162
}
163163

164-
let responseBody = try JSONDecoder.auth.decode(
164+
let responseBody = try JSONDecoder.api.decode(
165165
ResponseBody.self,
166166
from: responseData
167167
)

Sources/DropboxClient/Client.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ extension Client {
3030
pkceUtils: pkceUtils
3131
),
3232
listFolder: .live(
33-
keychain: keychain
33+
keychain: keychain,
34+
httpClient: httpClient
3435
)
3536
)
3637
}

Sources/DropboxClient/Coding.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
import Foundation
22

3+
extension DateFormatter {
4+
static let api: DateFormatter = {
5+
let formatter = DateFormatter()
6+
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
7+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
8+
return formatter
9+
}()
10+
}
11+
312
extension JSONDecoder {
4-
static let auth: JSONDecoder = {
13+
static let api: JSONDecoder = {
514
let decoder = JSONDecoder()
615
decoder.keyDecodingStrategy = .convertFromSnakeCase
16+
decoder.dateDecodingStrategy = .formatted(.api)
717
return decoder
818
}()
919
}
20+
21+
extension JSONEncoder {
22+
static let api: JSONEncoder = {
23+
let encoder = JSONEncoder()
24+
encoder.keyEncodingStrategy = .convertToSnakeCase
25+
encoder.dateEncodingStrategy = .formatted(.api)
26+
return encoder
27+
}()
28+
}

Sources/DropboxClient/ListFolder.swift

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
import Foundation
22

33
public struct ListFolder: Sendable {
4-
public struct Params: Sendable, Equatable {
5-
public init() {}
4+
public struct Params: Sendable, Equatable, Encodable {
5+
public init(
6+
path: String,
7+
recursive: Bool? = nil,
8+
includeDeleted: Bool? = nil,
9+
limit: Int? = nil,
10+
includeNonDownloadableFiles: Bool? = nil
11+
) {
12+
self.path = path
13+
self.recursive = recursive
14+
self.includeDeleted = includeDeleted
15+
self.limit = limit
16+
self.includeNonDownloadableFiles = includeNonDownloadableFiles
17+
}
18+
19+
public var path: String
20+
public var recursive: Bool?
21+
public var includeDeleted: Bool?
22+
public var limit: Int?
23+
public var includeNonDownloadableFiles: Bool?
624
}
725

8-
public struct Result: Sendable, Equatable {
26+
public struct Result: Sendable, Equatable, Decodable {
927
public init(cursor: String, entries: [Metadata], hasMore: Bool) {
1028
self.cursor = cursor
1129
self.entries = entries
@@ -19,7 +37,7 @@ public struct ListFolder: Sendable {
1937

2038
public enum Error: Swift.Error, Sendable, Equatable {
2139
case notAuthorized
22-
case unimplemented
40+
case response(statusCode: Int?, data: Data)
2341
}
2442

2543
public typealias Run = @Sendable (Params) async throws -> Result
@@ -33,17 +51,63 @@ public struct ListFolder: Sendable {
3351
public func callAsFunction(_ params: Params) async throws -> Result {
3452
try await run(params)
3553
}
54+
55+
public func callAsFunction(
56+
path: String,
57+
recursive: Bool? = nil,
58+
includeDeleted: Bool? = nil,
59+
limit: Int? = nil,
60+
includeNonDownloadableFiles: Bool? = nil
61+
) async throws -> Result {
62+
try await run(.init(
63+
path: path,
64+
recursive: recursive,
65+
includeDeleted: includeDeleted,
66+
limit: limit,
67+
includeNonDownloadableFiles: includeNonDownloadableFiles
68+
))
69+
}
3670
}
3771

3872
extension ListFolder {
3973
public static func live(
40-
keychain: Keychain
74+
keychain: Keychain,
75+
httpClient: HTTPClient
4176
) -> ListFolder {
4277
ListFolder { params in
43-
guard let _ = await keychain.loadCredentials() else {
78+
guard let credentials = await keychain.loadCredentials() else {
4479
throw Error.notAuthorized
4580
}
46-
throw Error.unimplemented
81+
82+
let request: URLRequest = try {
83+
var components = URLComponents()
84+
components.scheme = "https"
85+
components.host = "api.dropboxapi.com"
86+
components.path = "/2/files/list_folder"
87+
88+
var request = URLRequest(url: components.url!)
89+
request.httpMethod = "POST"
90+
request.setValue(
91+
"\(credentials.tokenType) \(credentials.accessToken)",
92+
forHTTPHeaderField: "Authorization"
93+
)
94+
request.setValue(
95+
"application/json",
96+
forHTTPHeaderField: "Content-Type"
97+
)
98+
request.httpBody = try JSONEncoder.api.encode(params)
99+
100+
return request
101+
}()
102+
103+
let (responseData, response) = try await httpClient.data(for: request)
104+
let statusCode = (response as? HTTPURLResponse)?.statusCode
105+
106+
guard let statusCode, (200..<300).contains(statusCode) else {
107+
throw Error.response(statusCode: statusCode, data: responseData)
108+
}
109+
110+
return try JSONDecoder.api.decode(Result.self, from: responseData)
47111
}
48112
}
49113
}

Sources/DropboxClient/Metadata.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
public struct Metadata: Sendable, Equatable, Identifiable, Codable {
3+
public struct Metadata: Sendable, Equatable, Identifiable {
44
public enum Tag: String, Sendable, Equatable, Codable {
55
case file, folder, deleted
66
}
@@ -12,7 +12,8 @@ public struct Metadata: Sendable, Equatable, Identifiable, Codable {
1212
pathDisplay: String,
1313
pathLower: String,
1414
clientModified: Date,
15-
serverModified: Date
15+
serverModified: Date,
16+
isDownloadable: Bool
1617
) {
1718
self.tag = tag
1819
self.id = id
@@ -21,6 +22,7 @@ public struct Metadata: Sendable, Equatable, Identifiable, Codable {
2122
self.pathLower = pathLower
2223
self.clientModified = clientModified
2324
self.serverModified = serverModified
25+
self.isDownloadable = isDownloadable
2426
}
2527

2628
public var tag: Tag
@@ -30,4 +32,18 @@ public struct Metadata: Sendable, Equatable, Identifiable, Codable {
3032
public var pathLower: String
3133
public var clientModified: Date
3234
public var serverModified: Date
35+
public var isDownloadable: Bool
36+
}
37+
38+
extension Metadata: Codable {
39+
enum CodingKeys: String, CodingKey {
40+
case tag = ".tag"
41+
case id
42+
case name
43+
case pathDisplay
44+
case pathLower
45+
case clientModified
46+
case serverModified
47+
case isDownloadable
48+
}
3349
}

Tests/DropboxClientTests/AuthTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ final class AuthTests: XCTestCase {
215215
let auth = Auth.live(
216216
config: .test,
217217
keychain: .unimplemented(),
218-
httpClient: .init { request in
218+
httpClient: .init { _ in
219219
(
220220
"Error!!!".data(using: .utf8)!,
221221
HTTPURLResponse(

Tests/DropboxClientTests/ListFolderTests.swift

Lines changed: 116 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,148 @@ import XCTest
22
@testable import DropboxClient
33

44
final class ListFolderTests: XCTestCase {
5-
func testListFolder() async {
5+
func testListFolder() async throws {
6+
let credentials = Credentials.test
7+
let httpRequests = ActorIsolated<[URLRequest]>([])
68
let listFolder = ListFolder.live(
79
keychain: {
810
var keychain = Keychain.unimplemented()
9-
keychain.loadCredentials = { .test }
11+
keychain.loadCredentials = { credentials }
1012
return keychain
13+
}(),
14+
httpClient: .init { request in
15+
await httpRequests.withValue { $0.append(request) }
16+
return (
17+
"""
18+
{
19+
"cursor": "c1234",
20+
"entries": [
21+
{
22+
".tag": "file",
23+
"id": "id:a4ayc_80_OEAAAAAAAAAXw",
24+
"name": "Prime_Numbers.txt",
25+
"path_display": "/Homework/math/Prime_Numbers.txt",
26+
"path_lower": "/homework/math/prime_numbers.txt",
27+
"client_modified": "2023-07-06T15:50:38Z",
28+
"server_modified": "2023-07-06T22:10:00Z",
29+
"is_downloadable": true
30+
}
31+
],
32+
"has_more": false
33+
}
34+
""".data(using: .utf8)!,
35+
HTTPURLResponse(
36+
url: URL(filePath: "/"),
37+
statusCode: 200,
38+
httpVersion: nil,
39+
headerFields: nil
40+
)!
41+
)
42+
}
43+
)
44+
45+
let result = try await listFolder(
46+
path: "test-path",
47+
recursive: true,
48+
includeDeleted: true,
49+
limit: 1234,
50+
includeNonDownloadableFiles: false
51+
)
52+
53+
await httpRequests.withValue {
54+
let expectedRequest: URLRequest = {
55+
let url = URL(string: "https://api.dropboxapi.com/2/files/list_folder")!
56+
var request = URLRequest(url: url)
57+
request.httpMethod = "POST"
58+
request.allHTTPHeaderFields = [
59+
"Authorization": "\(credentials.tokenType) \(credentials.accessToken)",
60+
"Content-Type": "application/json"
61+
]
62+
return request
1163
}()
64+
65+
XCTAssertEqual($0, [expectedRequest])
66+
}
67+
XCTAssertEqual(result, ListFolder.Result(
68+
cursor: "c1234",
69+
entries: [
70+
Metadata(
71+
tag: .file,
72+
id: "id:a4ayc_80_OEAAAAAAAAAXw",
73+
name: "Prime_Numbers.txt",
74+
pathDisplay: "/Homework/math/Prime_Numbers.txt",
75+
pathLower: "/homework/math/prime_numbers.txt",
76+
clientModified: Calendar(identifier: .gregorian)
77+
.date(from: DateComponents(
78+
timeZone: TimeZone(secondsFromGMT: 0),
79+
year: 2023, month: 7, day: 6,
80+
hour: 15, minute: 50, second: 38
81+
))!,
82+
serverModified: Calendar(identifier: .gregorian)
83+
.date(from: DateComponents(
84+
timeZone: TimeZone(secondsFromGMT: 0),
85+
year: 2023, month: 7, day: 6,
86+
hour: 22, minute: 10
87+
))!,
88+
isDownloadable: true
89+
)
90+
],
91+
hasMore: false
92+
))
93+
}
94+
95+
func testListFolderWhenNotAuthorized() async {
96+
let listFolder = ListFolder.live(
97+
keychain: {
98+
var keychain = Keychain.unimplemented()
99+
keychain.loadCredentials = { nil }
100+
return keychain
101+
}(),
102+
httpClient: .unimplemented()
12103
)
13104

14105
do {
15-
_ = try await listFolder(.init())
106+
_ = try await listFolder(.init(path: ""))
16107
XCTFail("Expected to throw, but didn't")
17108
} catch {
18109
XCTAssertEqual(
19-
error as? ListFolder.Error, .unimplemented,
110+
error as? ListFolder.Error, .notAuthorized,
20111
"Expected to throw .notAuthorized, got \(error)"
21112
)
22113
}
23114
}
24115

25-
func testListFolderWhenNotAuthorized() async {
116+
func testListFolderErrorResponse() async {
26117
let listFolder = ListFolder.live(
27118
keychain: {
28119
var keychain = Keychain.unimplemented()
29-
keychain.loadCredentials = { nil }
120+
keychain.loadCredentials = { .test }
30121
return keychain
31-
}()
122+
}(),
123+
httpClient: .init { _ in
124+
(
125+
"Error!!!".data(using: .utf8)!,
126+
HTTPURLResponse(
127+
url: URL(filePath: "/"),
128+
statusCode: 500,
129+
httpVersion: nil,
130+
headerFields: nil
131+
)!
132+
)
133+
}
32134
)
33135

34136
do {
35-
_ = try await listFolder(.init())
137+
_ = try await listFolder(.init(path: ""))
36138
XCTFail("Expected to throw, but didn't")
37139
} catch {
38140
XCTAssertEqual(
39-
error as? ListFolder.Error, .notAuthorized,
40-
"Expected to throw .notAuthorized, got \(error)"
141+
error as? ListFolder.Error,
142+
.response(
143+
statusCode: 500,
144+
data: "Error!!!".data(using: .utf8)!
145+
),
146+
"Expected to throw response error, got \(error)"
41147
)
42148
}
43149
}

0 commit comments

Comments
 (0)