diff --git a/Package.swift b/Package.swift index a465df1..ff622d0 100644 --- a/Package.swift +++ b/Package.swift @@ -9,10 +9,11 @@ let package = Package( ], dependencies: [ // 🗄 Storage abstraction framework. - .package(url: "https://github.com/gperdomor/storage-kit.git", from: "0.2.1") + .package(url: "https://github.com/gperdomor/storage-kit.git", from: "0.2.1"), + .package(url: "https://github.com/JohnSundell/Files.git", from: "2.2.1") ], targets: [ - .target(name: "LocalStorage", dependencies: ["StorageKit"]), + .target(name: "LocalStorage", dependencies: ["StorageKit", "Files"]), .testTarget(name: "LocalStorageTests", dependencies: ["LocalStorage"]) ] ) diff --git a/Sources/LocalStorage/LocalAdapter.swift b/Sources/LocalStorage/LocalAdapter.swift index aeb5727..c82b4f5 100644 --- a/Sources/LocalStorage/LocalAdapter.swift +++ b/Sources/LocalStorage/LocalAdapter.swift @@ -11,6 +11,7 @@ import Crypto import Foundation import StorageKit import Vapor +import Files extension AdapterIdentifier { /// The main Local adapter identifier. @@ -23,188 +24,37 @@ extension AdapterIdentifier { /// in the local filesystem. public class LocalAdapter: Adapter { /// A path to the root directory from which to read or write files. - private let directory: String - - /// POSIX permission value to set when the directory is created. - /// - note: Should be expressed as octal integer. - /// Default: `0o755`. - private let mode: Int - - private let fm = FileManager.default + private let directory: Folder /// Create a new Local adapter. /// /// - Parameters: /// - worker: the EventLoop worker /// - uploadDirectory: A path to the root directory from which to read or write files. - /// - mode: POSIX permission as octal integer. Default: `0o755`. - public init(rootDirectory: URL, create: Bool, mode: Int = 0o755) throws { - self.directory = "\(rootDirectory.path)" - self.mode = mode - - if create { - try self.create(directory: self.directory, mode: mode) - } - } -} - -extension LocalAdapter { - internal func compute(bucket: String, object: String? = nil) -> String { - var composed = "\(self.directory)/\(bucket)" - - if let object = object, !object.isEmpty { - composed += "/\(object)" - } - - return composed - } - - /// Creates the specified directory and its parents. - /// - /// - Parameters: - /// - path: Path of the directory to create. - /// - mode: Posix permission. - /// - Throws: <#throws value description#> - internal func create(directory path: String, mode: Int) throws { - try fm.createDirectory( - atPath: path, - withIntermediateDirectories: true, - attributes: [:] - ) - } - - /// <#Description#> - /// - /// - Parameter path: <#path description#> - /// - Throws: <#throws value description#> - internal func delete(directory path: String) throws { - try fm.removeItem(atPath: path) - } - - /// <#Description#> - /// - /// - Parameter bucket: <#bucket description#> - /// - Returns: <#return value description#> - /// - Throws: <#throws value description#> - internal func get(bucket: String) throws -> BucketInfo? { - let buckets = try self.list() - - return buckets.first(where: { buck in - buck.name == bucket - }) - } - - internal func get(object: String, in bucket: String) throws -> Data { - let path = self.compute(bucket: bucket, object: object) - - guard let data = fm.contents(atPath: path) else { - throw LocalAdapterError(identifier: "get object", reason: "can't retrieve object.", source: .capture()) - } - - return data - } - - internal func list() throws -> [BucketInfo] { - guard let url = self.directory.convertToURL() else { - throw LocalAdapterError(identifier: "list", reason: "unable to covert to URL", source: .capture()) - } - - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: [.skipsSubdirectoryDescendants]) - - let buckets: [BucketInfo] = try contents.compactMap { - let path = $0.path - let name = $0.lastPathComponent - - print("\(path) - \(self.isDirectory(at: path))") - - if !name.hasPrefix(".") && self.isDirectory(at: path) == true { - let attr = try fm.attributesOfItem(atPath: path) - return BucketInfo(name: name, creationDate: Date(rfc1123: attr[.creationDate] as? String ?? "")) - } - - return nil - } - - return buckets - } - - internal func listObjects(in bucket: String, prefix: String? = nil) throws -> [ObjectInfo] { - guard let url = self.compute(bucket: bucket).convertToURL() else { - throw LocalAdapterError(identifier: "listObjects", reason: "unable to covert to URL", source: .capture()) - } - - let contents = try fm.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: [.skipsSubdirectoryDescendants]) - let prefix: String = prefix ?? "" - - let objects: [ObjectInfo] = try contents.compactMap { - let path = $0.path - let name = $0.lastPathComponent - - if !name.hasPrefix(".") && !name.hasPrefix(prefix) { - return nil - } - - let attr = try fm.attributesOfItem(atPath: path) - - return ObjectInfo( - name: name, - prefix: prefix, - size: attr[.size] as? Int, - etag: try MD5.hash(self.get(object: name, in: bucket)).hexEncodedString(), - lastModified: Date(rfc1123: attr[.creationDate] as? String ?? ""), - url: nil - ) - } - - return objects - } - - /// Verify if the path is a directory. - /// - /// - Parameter path: the path. - /// - Returns: `true` if the path exists and is a directory, `false` in other cases`. - internal func isDirectory(at path: String) -> Bool { - var isDirectory: ObjCBool = false - fm.fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue + public init(rootDirectory: String) throws { + self.directory = try Folder(path: rootDirectory) } } extension LocalAdapter { /// See `copy` public func copy(object: String, from bucket: String, as targetObj: String, to targetBucket: String, on container: Container) throws -> EventLoopFuture { - let source = self.compute(bucket: bucket, object: object) - let target = self.compute(bucket: targetBucket, object: targetObj) - - try self.fm.copyItem(atPath: source, toPath: target) - - let data = try self.get(object: targetObj, in: targetBucket) + let srcData = try self.directory.subfolder(named: bucket).file(named: object).read() - let objectInfo = ObjectInfo( - name: targetObj, - prefix: nil, - size: data.count, - etag: try MD5.hash(data).hexEncodedString(), - lastModified: Date(), - url: nil - ) - - return Future.map(on: container) { objectInfo } + return try self.create(object: targetObj, in: targetBucket, with: srcData, metadata: nil, on: container) } /// See `Adapter.create` public func create(object: String, in bucket: String, with content: Data, metadata: StorageMetadata?, on container: Container) throws -> EventLoopFuture { - let path = self.compute(bucket: bucket, object: object) - - self.fm.createFile(atPath: path, contents: content) + let file = try self.directory.subfolder(named: bucket).createFile(named: object, contents: content) let objectInfo = ObjectInfo( name: object, prefix: nil, - size: content.count, + size: file.size(), etag: try MD5.hash(content).hexEncodedString(), - lastModified: Date(), - url: nil + lastModified: file.modificationDate, + url: file.path.convertToURL() ) return Future.map(on: container) { objectInfo } @@ -212,15 +62,13 @@ extension LocalAdapter { /// See `delete` public func delete(object: String, in bucket: String, on container: Container) throws -> EventLoopFuture { - let path = self.compute(bucket: bucket, object: object) - - try fm.removeItem(atPath: path) + try self.directory.subfolder(named: bucket).file(named: object).delete() return Future.map(on: container) { () } } public func get(object: String, in bucket: String, on container: Container) throws -> EventLoopFuture { - let data = try self.get(object: object, in: bucket) + let data = try self.directory.subfolder(named: bucket).file(named: object).read() return Future.map(on: container) { data } } @@ -228,45 +76,79 @@ extension LocalAdapter { extension LocalAdapter { public func create(bucket: String, metadata: StorageMetadata?, on container: Container) throws -> EventLoopFuture { - if try self.get(bucket: bucket) != nil { + if self.directory.containsSubfolder(named: bucket) { throw LocalAdapterError(identifier: "create bucket", reason: "Bucket '\(bucket)' already exists.", source: .capture()) } - let path = self.compute(bucket: bucket) - - try self.create(directory: path, mode: mode) + try self.directory.createSubfolder(named: bucket) return Future.map(on: container) { () } } public func delete(bucket: String, on container: Container) throws -> EventLoopFuture { - let path = self.compute(bucket: bucket) + guard self.directory.containsSubfolder(named: bucket) else { + throw LocalAdapterError(identifier: "delete bucket", reason: "Bucket '\(bucket)' not exists.", source: .capture()) + } + + let bucketFolder = try self.directory.subfolder(named: bucket) - guard try fm.contentsOfDirectory(atPath: path).isEmpty else { + guard bucketFolder.files.count == 0, bucketFolder.subfolders.count == 0 else { throw LocalAdapterError(identifier: "delete bucket", reason: "Bucket '\(bucket)' is not empty.", source: .capture()) } - try self.delete(directory: path) + try bucketFolder.delete() return Future.map(on: container) { () } } public func get(bucket: String, on container: Container) throws -> EventLoopFuture { - let bucketInfo = try self.get(bucket: bucket) + guard self.directory.containsSubfolder(named: bucket) else { + throw LocalAdapterError(identifier: "get bucket", reason: "Bucket '\(bucket)' not exists.", source: .capture()) + } + + let bucketFolder = try self.directory.subfolder(named: bucket) - return Future.map(on: container) { bucketInfo } + return Future.map(on: container) { BucketInfo(name: bucket, creationDate: bucketFolder.creationDate()) } } public func list(on container: Container) throws -> EventLoopFuture<[BucketInfo]> { - let buckets = try self.list() + let buckets = self.directory.subfolders.map { BucketInfo(name: $0.name, creationDate: $0.creationDate()) } return Future.map(on: container) { buckets } } - /// See `Adapter.listObjects` public func listObjects(in bucket: String, prefix: String?, on container: Container) throws -> EventLoopFuture<[ObjectInfo]> { - let objects = try self.listObjects(in: bucket, prefix: prefix) + guard self.directory.containsSubfolder(named: bucket) else { + throw LocalAdapterError(identifier: "listObjects", reason: "Bucket '\(bucket)' not exists.", source: .capture()) + } + + let bucketFolder = try self.directory.subfolder(named: bucket) + + let objects: [ObjectInfo] = try bucketFolder.files.compactMap { + if let p = prefix { + if !$0.name.hasPrefix(p) { + return nil + } + } + + let data = try $0.read() + return try ObjectInfo(name: $0.name, prefix: prefix, size: $0.size(), etag: MD5.hash(data).hexEncodedString(), lastModified: $0.modificationDate, url: $0.path.convertToURL()) + } return Future.map(on: container) { objects } } } + +extension FileSystem.Item { + func creationDate() -> Date { + let attributes = try! FileManager.default.attributesOfItem(atPath: path) + return attributes[FileAttributeKey.creationDate] as! Date + } +} + +extension Files.File { + func size() -> Int { + let attributes = try! FileManager.default.attributesOfItem(atPath: path) + return attributes[FileAttributeKey.size] as! Int + } +} diff --git a/Tests/LocalStorageTests/LocalAdapterTests.swift b/Tests/LocalStorageTests/LocalAdapterTests.swift index 846bb25..0753d18 100644 --- a/Tests/LocalStorageTests/LocalAdapterTests.swift +++ b/Tests/LocalStorageTests/LocalAdapterTests.swift @@ -9,6 +9,7 @@ import XCTest import Crypto import Vapor +import Files @testable import LocalStorage @@ -20,167 +21,163 @@ let TEST_DATA = "TEST DATA" final class LocalAdapterTests: XCTestCase { var app: Application! var adapter: LocalAdapter! - var rootDir: URL! + var rootDir: Folder! var fm = FileManager.default override func setUp() { super.setUp() app = try! Application.testable() - rootDir = fm.temporaryDirectory.appendingPathComponent(TEST_DIRECTORY) + rootDir = try! Folder.temporary.createSubfolderIfNeeded(withName: TEST_DIRECTORY) - adapter = try! LocalAdapter(rootDirectory: rootDir, create: true) + adapter = try! LocalAdapter(rootDirectory: rootDir.path) // create some buckets - let path = rootDir.path + try! rootDir.createSubfolderIfNeeded(withName: "bucket-1") + try! rootDir.createSubfolderIfNeeded(withName: "bucket-2") + let b3 = try! rootDir.createSubfolderIfNeeded(withName: "bucket-3") - try! fm.createDirectory(atPath: "\(path)/bucket-1", withIntermediateDirectories: true) - try! fm.createDirectory(atPath: "\(path)/bucket-2", withIntermediateDirectories: true) - try! fm.createDirectory(atPath: "\(path)/bucket-3", withIntermediateDirectories: true) - - fm.createFile(atPath: "\(path)/bucket-3/file.png", contents: TEST_DATA.convertToData()) - fm.createFile(atPath: "\(path)/bucket-3/other-file.txt", contents: TEST_DATA.convertToData()) + try! b3.createFile(named: "file.png", contents: TEST_DATA.convertToData()) + try! b3.createFile(named: "other-file.txt", contents: TEST_DATA.convertToData()) } override func tearDown() { super.tearDown() - try! fm.removeItem(at: rootDir) - } - - func testListHelper() throws { - try adapter.list() - } - - func testComputePath() throws { - XCTAssertEqual(adapter.compute(bucket: "bucket-1", object: nil), "\(rootDir.path)/bucket-1") - XCTAssertEqual(adapter.compute(bucket: "bucket-2", object: ""), "\(rootDir.path)/bucket-2") - XCTAssertEqual(adapter.compute(bucket: "bucket-3", object: "object1.png"), "\(rootDir.path)/bucket-3/object1.png") + try! rootDir.delete() } // MARK: Bucket tests func testCreateBucket() throws { - var path = rootDir.appendingPathComponent("create-1").path - - XCTAssertEqual(isDirectory(at: path), false) - _ = try adapter.create(bucket: "create-1", metadata: nil, on: app) - XCTAssertEqual(isDirectory(at: path), true) + var name = "create-1" + XCTAssertEqual(rootDir.containsSubfolder(named: name), false) + _ = try adapter.create(bucket: name, metadata: nil, on: app).wait() + XCTAssertEqual(rootDir.containsSubfolder(named: name), true) - path = rootDir.appendingPathComponent("create-2").path - - XCTAssertEqual(isDirectory(at: path), false) - _ = try adapter.create(bucket: "create-2", metadata: nil, on: app) - XCTAssertEqual(isDirectory(at: path), true) + name = "create-2" + XCTAssertEqual(rootDir.containsSubfolder(named: name), false) + _ = try adapter.create(bucket: name, metadata: nil, on: app).wait() + XCTAssertEqual(rootDir.containsSubfolder(named: name), true) } // MARK: Delete Bucket func testDeleteEmptyBucket() throws { - let path = rootDir.appendingPathComponent("bucket-1").path + let name = "bucket-1" - XCTAssertTrue(isDirectory(at: path)) - _ = try adapter.delete(bucket: "bucket-1", on: app) - XCTAssertFalse(isDirectory(at: path)) + XCTAssertTrue(rootDir.containsSubfolder(named: name)) + _ = try adapter.delete(bucket: name, on: app).wait() + XCTAssertFalse(rootDir.containsSubfolder(named: name)) } func testDeleteNonEmptyBucket() throws { - let path = rootDir.appendingPathComponent("bucket-3").path + let name = "bucket-3" - XCTAssertTrue(isDirectory(at: path)) - XCTAssertThrowsError(try adapter.delete(bucket: "bucket-3", on: app)) - XCTAssertTrue(isDirectory(at: path)) + XCTAssertTrue(rootDir.containsSubfolder(named: name)) + XCTAssertThrowsError(try adapter.delete(bucket: name, on: app).wait()) } func testDeleteUnknowBucket() throws { - let path = rootDir.appendingPathComponent("non-existence-bucket").path + let name = "non-existence-bucket" - XCTAssertFalse(isDirectory(at: path)) - XCTAssertThrowsError(try adapter.delete(bucket: "non-existence-bucket", on: app)) + XCTAssertFalse(rootDir.containsSubfolder(named: name)) + XCTAssertThrowsError(try adapter.delete(bucket: name, on: app).wait()) } // MARK: Get Bucket func testGetBucket() throws { - var bucket = try adapter.get(bucket: "bucket-1") + var name = "bucket-1" + var bucket = try adapter.get(bucket: name, on: app).wait() XCTAssertNotNil(bucket) - XCTAssertTrue(bucket!.name == "bucket-1") + XCTAssertEqual(bucket!.name, name) - bucket = try adapter.get(bucket: "bucket-2") + name = "bucket-2" + bucket = try adapter.get(bucket: name, on: app).wait() XCTAssertNotNil(bucket) - XCTAssertTrue(bucket!.name == "bucket-2") - - bucket = try adapter.get(bucket: "non-existing-bucket") + XCTAssertEqual(bucket!.name, name) - XCTAssertNil(bucket) + name = "bucket-3" + bucket = try adapter.get(bucket: name, on: app).wait() - let future = try adapter.get(bucket: "bucket-3", on: app) + XCTAssertNotNil(bucket) + XCTAssertEqual(bucket!.name, name) - XCTAssertNotNil(try future.wait()) - XCTAssertEqual(try future.wait()!.name, "bucket-3") + name = "non-existing-bucket" + XCTAssertThrowsError(try adapter.get(bucket: name, on: app).wait()) } func testListBuckets() throws { - let plain = try adapter.list() + let buckets = try adapter.list(on: app).wait() - XCTAssertEqual(plain.count, 3) + XCTAssertEqual(buckets.count, 3) ["bucket-1", "bucket-2", "bucket-3"].forEach { name in - XCTAssertTrue(plain.contains(where: { $0.name == name})) - } - - let future = try adapter.list(on: app) - - try ["bucket-1", "bucket-2", "bucket-3"].forEach { name in - XCTAssertTrue(try future.wait().contains(where: { $0.name == name})) + XCTAssertTrue(buckets.contains(where: { $0.name == name})) } } // MARK: Objects tests func testCopyObject() throws { - let targetPath = "\(rootDir.path)/bucket-2/file-copied.png" + let srcBucket = "bucket-3" + let srcObject = "file.png" - XCTAssertFalse(fm.fileExists(atPath: targetPath)) - let future = try adapter.copy(object: "file.png", from: "bucket-3", as: "file-copied.png", to: "bucket-2", on: app) - XCTAssertTrue(fm.fileExists(atPath: targetPath)) + let targetBucket = "bucket-2" + let targetObject = "file-copied.png" - XCTAssertEqual(try future.wait().name, "file-copied.png") + XCTAssertFalse(try rootDir.subfolder(named: targetBucket).containsFile(named: targetObject)) - let sourceData = fm.contents(atPath: "\(rootDir.path)/bucket-3/file.png") - let targetData = fm.contents(atPath: targetPath) + let result = try adapter.copy(object: srcObject, from: srcBucket, as: targetObject, to: targetBucket, on: app).wait() + XCTAssertTrue(try rootDir.subfolder(named: targetBucket).containsFile(named: targetObject)) + + XCTAssertEqual(result.name, "file-copied.png") + + let sourceData = try rootDir.subfolder(named: srcBucket).file(named: srcObject).read() + let targetData = try rootDir.subfolder(named: targetBucket).file(named: targetObject).read() XCTAssertEqual(sourceData, targetData) } func testCreateObject() throws { - XCTAssertFalse(fm.fileExists(atPath: "\(rootDir.path)/bucket-1/f1")) - + let bucket = "bucket-1" + var object = "f1" var data = Data() - var object = try adapter.create(object: "f1", in: "bucket-1", with: data, metadata: nil, on: app).wait() - XCTAssertTrue(fm.fileExists(atPath: "\(rootDir.path)/bucket-1/f1")) - XCTAssertEqual(object.name, "f1") - XCTAssertEqual(object.etag, try MD5.hash(data).hexEncodedString()) + XCTAssertFalse(try rootDir.subfolder(named: bucket).containsFile(named: object)) - XCTAssertFalse(fm.fileExists(atPath: "\(rootDir.path)/bucket-1/f2")) + var result = try adapter.create(object: object, in: bucket, with: data, metadata: nil, on: app).wait() + XCTAssertTrue(try rootDir.subfolder(named: bucket).containsFile(named: object)) + + XCTAssertEqual(result.name, object) + XCTAssertEqual(result.etag, try MD5.hash(data).hexEncodedString()) + + object = "f2" data = Data(count: 20) - object = try adapter.create(object: "f2", in: "bucket-1", with: data, metadata: nil, on: app).wait() - XCTAssertTrue(fm.fileExists(atPath: "\(rootDir.path)/bucket-1/f2")) - XCTAssertEqual(object.name, "f2") - XCTAssertEqual(object.etag, try MD5.hash(data).hexEncodedString()) + XCTAssertFalse(try rootDir.subfolder(named: bucket).containsFile(named: object)) + + result = try adapter.create(object: object, in: bucket, with: data, metadata: nil, on: app).wait() + + XCTAssertTrue(try rootDir.subfolder(named: bucket).containsFile(named: object)) + + XCTAssertEqual(result.name, object) + XCTAssertEqual(result.etag, try MD5.hash(data).hexEncodedString()) } func testDeleteObject() throws { - XCTAssertTrue(fm.fileExists(atPath: "\(rootDir.path)/bucket-3/file.png")) + let bucket = "bucket-3" + let object = "file.png" + + XCTAssertTrue(try rootDir.subfolder(named: bucket).containsFile(named: object)) - try adapter.delete(object: "file.png", in: "bucket-3", on: app).wait() + try adapter.delete(object: object, in: bucket, on: app).wait() - XCTAssertFalse(fm.fileExists(atPath: "\(rootDir.path)/bucket-3/file.png")) + XCTAssertFalse(try rootDir.subfolder(named: bucket).containsFile(named: object)) } func testGetObject() throws { @@ -190,17 +187,17 @@ final class LocalAdapterTests: XCTestCase { } func testListObjects() throws { - var list = try adapter.listObjects(in: "bucket-3", prefix: nil) + var objects = try adapter.listObjects(in: "bucket-3", prefix: nil, on: app).wait() - XCTAssertEqual(list.count, 2) + XCTAssertEqual(objects.count, 2) ["file.png", "other-file.txt"].forEach { name in - XCTAssertTrue(list.contains(where: { $0.name == name})) + XCTAssertTrue(objects.contains(where: { $0.name == name})) } - list = try adapter.listObjects(in: "bucket-3", prefix: "oth") - XCTAssertEqual(list.count, 1) - XCTAssertEqual(list[0].name, "other-file.txt") + objects = try adapter.listObjects(in: "bucket-3", prefix: "oth", on: app).wait() + XCTAssertEqual(objects.count, 1) + XCTAssertEqual(objects[0].name, "other-file.txt") } func testLinuxTestSuiteIncludesAllTests() throws { @@ -215,9 +212,6 @@ final class LocalAdapterTests: XCTestCase { static var allTests = [ ("testLinuxTestSuiteIncludesAllTests", testLinuxTestSuiteIncludesAllTests), - // Helpers - ("testListHelper", testListHelper), - ("testComputePath", testComputePath), ("testCreateBucket", testCreateBucket), ("testDeleteEmptyBucket", testDeleteEmptyBucket), ("testDeleteNonEmptyBucket", testDeleteNonEmptyBucket), @@ -231,13 +225,3 @@ final class LocalAdapterTests: XCTestCase { ("testListObjects", testListObjects) ] } - -/// Verify if the path is a directory. -/// -/// - Parameter path: the path. -/// - Returns: `true` if the path exists and is a directory, `false` in other cases`. -internal func isDirectory(at path: String) -> Bool { - var isDirectory: ObjCBool = false - FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - return isDirectory.boolValue -}