diff --git a/Package.swift b/Package.swift index e410e6754..adef1f2c7 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,7 @@ let package = Package( .product(name: "Metrics", package: "swift-metrics"), .product(name: "Tracing", package: "swift-distributed-tracing"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "_NIOFileSystem", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), ], swiftSettings: swiftSettings diff --git a/Sources/Hummingbird/Environment.swift b/Sources/Hummingbird/Environment.swift index 4a1a35e9e..93b7235ac 100644 --- a/Sources/Hummingbird/Environment.swift +++ b/Sources/Hummingbird/Environment.swift @@ -14,6 +14,7 @@ import HummingbirdCore import NIOCore +import NIOFileSystem #if canImport(FoundationEssentials) import FoundationEssentials @@ -172,18 +173,10 @@ public struct Environment: Sendable, Decodable, ExpressibleByDictionaryLiteral { /// Load `.env` file into string internal static func loadDotEnv(_ dotEnvPath: String = ".env") async -> String? { do { - let fileHandle = try NIOFileHandle(path: dotEnvPath) - defer { - try? fileHandle.close() + return try await FileSystem.shared.withFileHandle(forReadingAt: .init(dotEnvPath)) { fileHandle in + let buffer = try await fileHandle.readToEnd(maximumSizeAllowed: .unlimited) + return String(buffer: buffer) } - let fileRegion = try FileRegion(fileHandle: fileHandle) - let contents = try fileHandle.withUnsafeFileDescriptor { descriptor in - [UInt8](unsafeUninitializedCapacity: fileRegion.readableBytes) { bytes, size in - size = fileRegion.readableBytes - read(descriptor, .init(bytes.baseAddress), size) - } - } - return String(bytes: contents, encoding: .utf8) } catch { return nil } diff --git a/Sources/Hummingbird/Files/FileIO.swift b/Sources/Hummingbird/Files/FileIO.swift index d0c727ff3..284c9550e 100644 --- a/Sources/Hummingbird/Files/FileIO.swift +++ b/Sources/Hummingbird/Files/FileIO.swift @@ -16,15 +16,24 @@ import HummingbirdCore import Logging import NIOCore import NIOPosix +import NIOFileSystem /// Manages File reading and writing. public struct FileIO: Sendable { - let fileIO: NonBlockingFileIO + struct FileError: Error { + internal enum Value { + case fileDoesNotExist + } + internal let value: Value + + static var fileDoesNotExist: Self { .init(value: .fileDoesNotExist) } + } + let fileSystem: FileSystem /// Initialize FileIO /// - Parameter threadPool: ThreadPool to use for file operations public init(threadPool: NIOThreadPool = .singleton) { - self.fileIO = .init(threadPool: threadPool) + self.fileSystem = .init(threadPool: threadPool) } /// Load file and return response body @@ -39,12 +48,12 @@ public struct FileIO: Sendable { public func loadFile( path: String, context: some RequestContext, - chunkLength: Int = NonBlockingFileIO.defaultChunkSize + chunkLength: Int = 128 * 1024 ) async throws -> ResponseBody { do { - let stat = try await fileIO.stat(path: path) - guard stat.st_size > 0 else { return .init() } - return self.readFile(path: path, range: 0...numericCast(stat.st_size - 1), context: context, chunkLength: chunkLength) + guard let info = try await self.fileSystem.info(forFileAt: .init(path)) else { throw FileError.fileDoesNotExist } + guard info.size > 0 else { return .init() } + return self.readFile(path: path, range: 0...numericCast(info.size - 1), context: context, chunkLength: chunkLength) } catch { throw HTTPError(.notFound) } @@ -64,12 +73,12 @@ public struct FileIO: Sendable { path: String, range: ClosedRange, context: some RequestContext, - chunkLength: Int = NonBlockingFileIO.defaultChunkSize + chunkLength: Int = 128 * 1024 ) async throws -> ResponseBody { do { - let stat = try await fileIO.stat(path: path) - guard stat.st_size > 0 else { return .init() } - let fileRange: ClosedRange = 0...numericCast(stat.st_size - 1) + guard let info = try await self.fileSystem.info(forFileAt: .init(path)) else { throw FileError.fileDoesNotExist } + guard info.size > 0 else { return .init() } + let fileRange: ClosedRange = 0...numericCast(info.size - 1) let range = range.clamped(to: fileRange) return self.readFile(path: path, range: range, context: context, chunkLength: chunkLength) } catch { @@ -89,9 +98,12 @@ public struct FileIO: Sendable { context: some RequestContext ) async throws where AS.Element == ByteBuffer { context.logger.debug("[FileIO] PUT", metadata: ["hb.file.path": .string(path)]) - try await self.fileIO.withFileHandle(path: path, mode: .write, flags: .allowFileCreation()) { handle in - for try await buffer in contents { - try await self.fileIO.write(fileHandle: handle, buffer: buffer) + try await self.fileSystem.withFileHandle( + forWritingAt: .init(path), + options: .newFile(replaceExisting: true) + ) { fileHandle in + try await fileHandle.withBufferedWriter { writer in + _ = try await writer.write(contentsOf: contents) } } } @@ -108,8 +120,11 @@ public struct FileIO: Sendable { context: some RequestContext ) async throws { context.logger.debug("[FileIO] PUT", metadata: ["hb.file.path": .string(path)]) - try await self.fileIO.withFileHandle(path: path, mode: .write, flags: .allowFileCreation()) { handle in - try await self.fileIO.write(fileHandle: handle, buffer: buffer) + try await self.fileSystem.withFileHandle( + forWritingAt: .init(path), + options: .newFile(replaceExisting: true) + ) { fileHandle in + _ = try await fileHandle.write(contentsOf: buffer, toAbsoluteOffset: 0) } } @@ -118,41 +133,18 @@ public struct FileIO: Sendable { path: String, range: ClosedRange, context: some RequestContext, - chunkLength: Int = NonBlockingFileIO.defaultChunkSize + chunkLength: Int ) -> ResponseBody { ResponseBody(contentLength: range.count) { writer in - try await self.fileIO.withFileHandle(path: path, mode: .read) { handle in - let endOffset = range.endIndex - let chunkLength = chunkLength - var fileOffset = range.startIndex - let allocator = ByteBufferAllocator() + try await self.fileSystem.withFileHandle(forReadingAt: .init(path)) { fileHandle in + let startOffset: Int64 = numericCast(range.lowerBound) + let endOffset: Int64 = numericCast(range.upperBound) - while case .inRange(let offset) = fileOffset { - let bytesLeft = range.distance(from: fileOffset, to: endOffset) - let bytesToRead = Swift.min(chunkLength, bytesLeft) - let buffer = try await self.fileIO.read( - fileHandle: handle, - fromOffset: numericCast(offset), - byteCount: bytesToRead, - allocator: allocator - ) - fileOffset = range.index(fileOffset, offsetBy: bytesToRead) - try await writer.write(buffer) + for try await chunk in fileHandle.readChunks(in: startOffset...endOffset, chunkLength: .bytes(numericCast(chunkLength))) { + try await writer.write(chunk) } try await writer.finish(nil) } } } } - -extension NonBlockingFileIO { - func stat(path: String) async throws -> stat { - let stat = try await self.lstat(path: path) - if stat.st_mode & S_IFMT == S_IFLNK { - let realPath = try await self.readlink(path: path) - return try await self.lstat(path: realPath) - } else { - return stat - } - } -} diff --git a/Sources/Hummingbird/Files/LocalFileSystem.swift b/Sources/Hummingbird/Files/LocalFileSystem.swift index e4bca7eb8..fa95d8cfa 100644 --- a/Sources/Hummingbird/Files/LocalFileSystem.swift +++ b/Sources/Hummingbird/Files/LocalFileSystem.swift @@ -89,16 +89,12 @@ public struct LocalFileSystem: FileProvider { /// - Returns: File attributes public func getAttributes(id path: FileIdentifier) async throws -> FileAttributes? { do { - let stat = try await self.fileIO.fileIO.stat(path: path) - let isFolder = (stat.st_mode & S_IFMT) == S_IFDIR - #if os(Linux) || os(Android) - let modificationDate = Double(stat.st_mtim.tv_sec) + (Double(stat.st_mtim.tv_nsec) / 1_000_000_000.0) - #else - let modificationDate = Double(stat.st_mtimespec.tv_sec) + (Double(stat.st_mtimespec.tv_nsec) / 1_000_000_000.0) - #endif + guard let info = try await self.fileIO.fileSystem.info(forFileAt: .init(path)) else { throw FileIO.FileError.fileDoesNotExist } + let isFolder = info.type == .directory + let modificationDate = Double(info.lastDataModificationTime.seconds) return .init( isFolder: isFolder, - size: numericCast(stat.st_size), + size: numericCast(info.size), modificationDate: Date(timeIntervalSince1970: modificationDate) ) } catch { diff --git a/Tests/HummingbirdTests/FileIOTests.swift b/Tests/HummingbirdTests/FileIOTests.swift index 10dd25357..9ebe22943 100644 --- a/Tests/HummingbirdTests/FileIOTests.swift +++ b/Tests/HummingbirdTests/FileIOTests.swift @@ -15,6 +15,7 @@ import Hummingbird import HummingbirdTesting import XCTest +import NIOFileSystem final class FileIOTests: XCTestCase { static func randomBuffer(size: Int) -> ByteBuffer { @@ -23,6 +24,25 @@ final class FileIOTests: XCTestCase { return ByteBufferAllocator().buffer(bytes: data) } + static func withFile( + _ path: String, + contents: Buffer, + process: () async throws -> ReturnValue + ) async throws -> ReturnValue where Buffer.Element == UInt8 { + let fileSystem = FileSystem(threadPool: .singleton) + try await fileSystem.withFileHandle(forWritingAt: .init(path)) { write in + _ = try await write.write(contentsOf: contents, toAbsoluteOffset: 0) + } + do { + let value = try await process() + _ = try? await fileSystem.removeItem(at: .init(path)) + return value + } catch { + _ = try? await fileSystem.removeItem(at: .init(path)) + throw error + } + } + func testReadFileIO() async throws { let router = Router() router.get("test.jpg") { _, context -> Response in @@ -30,17 +50,15 @@ final class FileIOTests: XCTestCase { let body = try await fileIO.loadFile(path: "testReadFileIO.jpg", context: context) return .init(status: .ok, headers: [:], body: body) } - let buffer = Self.randomBuffer(size: 320_003) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: "testReadFileIO.jpg") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - let app = Application(responder: router.buildResponder()) - try await app.test(.router) { client in - try await client.execute(uri: "/test.jpg", method: .get) { response in - XCTAssertEqual(response.body, buffer) + let buffer = Self.randomBuffer(size: 320_003) + + try await FileIOTests.withFile("testReadFileIO.jpg", contents: buffer.readableBytesView) { + try await app.test(.router) { client in + try await client.execute(uri: "/test.jpg", method: .get) { response in + XCTAssertEqual(response.body, buffer) + } } } } @@ -53,19 +71,17 @@ final class FileIOTests: XCTestCase { return .init(status: .ok, headers: [:], body: body) } let buffer = Self.randomBuffer(size: 54003) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: "testReadMultipleFilesOnSameConnection.jpg") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } let app = Application(responder: router.buildResponder()) - try await app.test(.live) { client in - try await client.execute(uri: "/test.jpg", method: .get) { response in - XCTAssertEqual(response.body, buffer) - } - try await client.execute(uri: "/test.jpg", method: .get) { response in - XCTAssertEqual(response.body, buffer) + try await FileIOTests.withFile("testReadMultipleFilesOnSameConnection.jpg", contents: buffer.readableBytesView) { + try await app.test(.live) { client in + try await client.execute(uri: "/test.jpg", method: .get) { response in + XCTAssertEqual(response.body, buffer) + } + try await client.execute(uri: "/test.jpg", method: .get) { response in + XCTAssertEqual(response.body, buffer) + } } } } @@ -87,10 +103,11 @@ final class FileIOTests: XCTestCase { } } - let fileURL = URL(fileURLWithPath: filename) - let data = try Data(contentsOf: fileURL) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - XCTAssertEqual(String(decoding: data, as: Unicode.UTF8.self), "This is a test") + let contents = try await FileSystem.shared.withFileHandle(forReadingAt: .init(filename)) { read in + try await read.readToEnd(fromAbsoluteOffset: 0, maximumSizeAllowed: .unlimited) + } + try await FileSystem.shared.removeItem(at: .init(filename)) + XCTAssertEqual(String(buffer: contents), "This is a test") } func testWriteLargeFile() async throws { @@ -109,10 +126,11 @@ final class FileIOTests: XCTestCase { XCTAssertEqual(response.status, .ok) } - let fileURL = URL(fileURLWithPath: filename) - let data = try Data(contentsOf: fileURL) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - XCTAssertEqual(Data(buffer: buffer), data) + let contents = try await FileSystem.shared.withFileHandle(forReadingAt: .init(filename)) { read in + try await read.readToEnd(fromAbsoluteOffset: 0, maximumSizeAllowed: .unlimited) + } + try await FileSystem.shared.removeItem(at: .init(filename)) + XCTAssertEqual(contents, buffer) } } @@ -123,16 +141,15 @@ final class FileIOTests: XCTestCase { let body = try await fileIO.loadFile(path: "empty.txt", context: context) return .init(status: .ok, headers: [:], body: body) } - let data = Data() - let fileURL = URL(fileURLWithPath: "empty.txt") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - let app = Application(responder: router.buildResponder()) - try await app.test(.router) { client in - try await client.execute(uri: "/empty.txt", method: .get) { response in - XCTAssertEqual(response.status, .ok) + let buffer = ByteBuffer() + + try await FileIOTests.withFile("empty.txt", contents: buffer.readableBytesView) { + try await app.test(.router) { client in + try await client.execute(uri: "/empty.txt", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } } } } @@ -144,16 +161,16 @@ final class FileIOTests: XCTestCase { let body = try await fileIO.loadFile(path: "empty.txt", range: 0...10, context: context) return .init(status: .ok, headers: [:], body: body) } - let data = Data() - let fileURL = URL(fileURLWithPath: "empty.txt") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } let app = Application(responder: router.buildResponder()) - try await app.test(.router) { client in - try await client.execute(uri: "/empty.txt", method: .get) { response in - XCTAssertEqual(response.status, .ok) + let buffer = ByteBuffer() + + try await FileIOTests.withFile("empty.txt", contents: buffer.readableBytesView) { + try await app.test(.router) { client in + try await client.execute(uri: "/empty.txt", method: .get) { response in + XCTAssertEqual(response.status, .ok) + } } } } diff --git a/Tests/HummingbirdTests/FileMiddlewareTests.swift b/Tests/HummingbirdTests/FileMiddlewareTests.swift index 2fd877575..85460bd77 100644 --- a/Tests/HummingbirdTests/FileMiddlewareTests.swift +++ b/Tests/HummingbirdTests/FileMiddlewareTests.swift @@ -16,6 +16,7 @@ import Foundation import HTTPTypes import Hummingbird import HummingbirdTesting +import NIOFileSystem import NIOPosix import XCTest @@ -41,15 +42,13 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).jpg" let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: filename, method: .get) { response in - XCTAssertEqual(String(buffer: response.body), text) - XCTAssertEqual(response.headers[.contentType], "image/jpeg") + try await FileIOTests.withFile(filename, contents: text.utf8) { + try await app.test(.router) { client in + try await client.execute(uri: filename, method: .get) { response in + XCTAssertEqual(String(buffer: response.body), text) + XCTAssertEqual(response.headers[.contentType], "image/jpeg") + } } } } @@ -73,14 +72,12 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 380_000) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: filename, method: .get) { response in - XCTAssertEqual(response.body, buffer) + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + try await client.execute(uri: filename, method: .get) { response in + XCTAssertEqual(response.body, buffer) + } } } } @@ -92,40 +89,38 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 326_000) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=100-3999"]) { response in - let slice = buffer.getSlice(at: 100, length: 3900) - XCTAssertEqual(response.body, slice) - XCTAssertEqual(response.headers[.contentRange], "bytes 100-3999/326000") - XCTAssertEqual(response.headers[.contentLength], "3900") - XCTAssertEqual(response.headers[.contentType], "text/plain") - } + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=100-3999"]) { response in + let slice = buffer.getSlice(at: 100, length: 3900) + XCTAssertEqual(response.body, slice) + XCTAssertEqual(response.headers[.contentRange], "bytes 100-3999/326000") + XCTAssertEqual(response.headers[.contentLength], "3900") + XCTAssertEqual(response.headers[.contentType], "text/plain") + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=0-0"]) { response in - let slice = buffer.getSlice(at: 0, length: 1) - XCTAssertEqual(response.body, slice) - XCTAssertEqual(response.headers[.contentRange], "bytes 0-0/326000") - XCTAssertEqual(response.headers[.contentLength], "1") - XCTAssertEqual(response.headers[.contentType], "text/plain") - } + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=0-0"]) { response in + let slice = buffer.getSlice(at: 0, length: 1) + XCTAssertEqual(response.body, slice) + XCTAssertEqual(response.headers[.contentRange], "bytes 0-0/326000") + XCTAssertEqual(response.headers[.contentLength], "1") + XCTAssertEqual(response.headers[.contentType], "text/plain") + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=-3999"]) { response in - let slice = buffer.getSlice(at: 0, length: 4000) - XCTAssertEqual(response.body, slice) - XCTAssertEqual(response.headers[.contentLength], "4000") - XCTAssertEqual(response.headers[.contentRange], "bytes 0-3999/326000") - } + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=-3999"]) { response in + let slice = buffer.getSlice(at: 0, length: 4000) + XCTAssertEqual(response.body, slice) + XCTAssertEqual(response.headers[.contentLength], "4000") + XCTAssertEqual(response.headers[.contentRange], "bytes 0-3999/326000") + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=6000-"]) { response in - let slice = buffer.getSlice(at: 6000, length: 320_000) - XCTAssertEqual(response.body, slice) - XCTAssertEqual(response.headers[.contentLength], "320000") - XCTAssertEqual(response.headers[.contentRange], "bytes 6000-325999/326000") + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=6000-"]) { response in + let slice = buffer.getSlice(at: 6000, length: 320_000) + XCTAssertEqual(response.body, slice) + XCTAssertEqual(response.headers[.contentLength], "320000") + XCTAssertEqual(response.headers[.contentRange], "bytes 6000-325999/326000") + } } } } @@ -137,32 +132,30 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 10000) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - let (eTag, modificationDate) = try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=-3999"]) { - response -> (String, String) in - let eTag = try XCTUnwrap(response.headers[.eTag]) - let modificationDate = try XCTUnwrap(response.headers[.lastModified]) - let slice = buffer.getSlice(at: 0, length: 4000) - XCTAssertEqual(response.body, slice) - XCTAssertEqual(response.headers[.contentRange], "bytes 0-3999/10000") - return (eTag, modificationDate) - } + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + let (eTag, modificationDate) = try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=-3999"]) { + response -> (String, String) in + let eTag = try XCTUnwrap(response.headers[.eTag]) + let modificationDate = try XCTUnwrap(response.headers[.lastModified]) + let slice = buffer.getSlice(at: 0, length: 4000) + XCTAssertEqual(response.body, slice) + XCTAssertEqual(response.headers[.contentRange], "bytes 0-3999/10000") + return (eTag, modificationDate) + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: eTag]) { response in - XCTAssertEqual(response.headers[.contentRange], "bytes 4000-9999/10000") - } + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: eTag]) { response in + XCTAssertEqual(response.headers[.contentRange], "bytes 4000-9999/10000") + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: modificationDate]) { response in - XCTAssertEqual(response.headers[.contentRange], "bytes 4000-9999/10000") - } + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: modificationDate]) { response in + XCTAssertEqual(response.headers[.contentRange], "bytes 4000-9999/10000") + } - try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: "not valid"]) { response in - XCTAssertNil(response.headers[.contentRange]) + try await client.execute(uri: filename, method: .get, headers: [.range: "bytes=4000-", .ifRange: "not valid"]) { response in + XCTAssertNil(response.headers[.contentRange]) + } } } } @@ -172,21 +165,21 @@ final class FileMiddlewareTests: XCTestCase { router.middlewares.add(FileMiddleware(".")) let app = Application(responder: router.buildResponder()) + let filename = "testHead.txt" let date = Date() let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: "testHead.txt") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: "/testHead.txt", method: .head) { response in - XCTAssertEqual(response.body.readableBytes, 0) - XCTAssertEqual(response.headers[.contentLength], text.utf8.count.description) - XCTAssertEqual(response.headers[.contentType], "text/plain") - let responseDateString = try XCTUnwrap(response.headers[.lastModified]) - let responseDate = try XCTUnwrap(Self.rfc9110Formatter.date(from: responseDateString)) - XCTAssert(date < responseDate + 2 && date > responseDate - 2) + try await FileIOTests.withFile(filename, contents: text.utf8) { + try await app.test(.router) { client in + let filename = "testHead.txt" + try await client.execute(uri: "/\(filename)", method: .head) { response in + XCTAssertEqual(response.body.readableBytes, 0) + XCTAssertEqual(response.headers[.contentLength], text.utf8.count.description) + XCTAssertEqual(response.headers[.contentType], "text/plain") + let responseDateString = try XCTUnwrap(response.headers[.lastModified]) + let responseDate = try XCTUnwrap(Self.rfc9110Formatter.date(from: responseDateString)) + XCTAssert(date < responseDate + 2 && date > responseDate - 2) + } } } } @@ -198,18 +191,16 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 16200) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - var eTag: String? - try await client.execute(uri: filename, method: .head) { response in - eTag = try XCTUnwrap(response.headers[.eTag]) - } - try await client.execute(uri: filename, method: .head) { response in - XCTAssertEqual(response.headers[.eTag], eTag) + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + var eTag: String? + try await client.execute(uri: filename, method: .head) { response in + eTag = try XCTUnwrap(response.headers[.eTag]) + } + try await client.execute(uri: filename, method: .head) { response in + XCTAssertEqual(response.headers[.eTag], eTag) + } } } } @@ -221,25 +212,23 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 16200) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - let eTag = try await client.execute(uri: filename, method: .head) { response in - try XCTUnwrap(response.headers[.eTag]) - } - try await client.execute(uri: filename, method: .get, headers: [.ifNoneMatch: eTag]) { response in - XCTAssertEqual(response.status, .notModified) - } - var headers: HTTPFields = .init() - headers[values: .ifNoneMatch] = ["test", "\(eTag)"] - try await client.execute(uri: filename, method: .get, headers: headers) { response in - XCTAssertEqual(response.status, .notModified) - } - try await client.execute(uri: filename, method: .get, headers: [.ifNoneMatch: "dummyETag"]) { response in - XCTAssertEqual(response.status, .ok) + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + let eTag = try await client.execute(uri: filename, method: .head) { response in + try XCTUnwrap(response.headers[.eTag]) + } + try await client.execute(uri: filename, method: .get, headers: [.ifNoneMatch: eTag]) { response in + XCTAssertEqual(response.status, .notModified) + } + var headers: HTTPFields = .init() + headers[values: .ifNoneMatch] = ["test", "\(eTag)"] + try await client.execute(uri: filename, method: .get, headers: headers) { response in + XCTAssertEqual(response.status, .notModified) + } + try await client.execute(uri: filename, method: .get, headers: [.ifNoneMatch: "dummyETag"]) { response in + XCTAssertEqual(response.status, .ok) + } } } } @@ -251,22 +240,20 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let buffer = Self.randomBuffer(size: 16200) - let data = Data(buffer: buffer) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - let modifiedDate = try await client.execute(uri: filename, method: .head) { response in - try XCTUnwrap(response.headers[.lastModified]) - } - try await client.execute(uri: filename, method: .get, headers: [.ifModifiedSince: modifiedDate]) { response in - XCTAssertEqual(response.status, .notModified) - } - // one minute before current date - let date = try XCTUnwrap(Self.rfc9110Formatter.string(from: Date(timeIntervalSinceNow: -60))) - try await client.execute(uri: filename, method: .get, headers: [.ifModifiedSince: date]) { response in - XCTAssertEqual(response.status, .ok) + try await FileIOTests.withFile(filename, contents: buffer.readableBytesView) { + try await app.test(.router) { client in + let modifiedDate = try await client.execute(uri: filename, method: .head) { response in + try XCTUnwrap(response.headers[.lastModified]) + } + try await client.execute(uri: filename, method: .get, headers: [.ifModifiedSince: modifiedDate]) { response in + XCTAssertEqual(response.status, .notModified) + } + // one minute before current date + let date = try XCTUnwrap(Self.rfc9110Formatter.string(from: Date(timeIntervalSinceNow: -60))) + try await client.execute(uri: filename, method: .get, headers: [.ifModifiedSince: date]) { response in + XCTAssertEqual(response.status, .ok) + } } } } @@ -282,20 +269,18 @@ final class FileMiddlewareTests: XCTestCase { let filename = "\(#function).txt" let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: filename) - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - let fileURL2 = URL(fileURLWithPath: "test.jpg") - XCTAssertNoThrow(try data.write(to: fileURL2)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL2)) } - - try await app.test(.router) { client in - try await client.execute(uri: filename, method: .get) { response in - XCTAssertEqual(response.headers[.cacheControl], "max-age=2592000") - } - try await client.execute(uri: "/test.jpg", method: .get) { response in - XCTAssertEqual(response.headers[.cacheControl], "max-age=2592000, private") + let filename2 = "\(#function).jpg" + + try await FileIOTests.withFile(filename, contents: text.utf8) { + try await FileIOTests.withFile(filename2, contents: text.utf8) { + try await app.test(.router) { client in + try await client.execute(uri: filename, method: .get) { response in + XCTAssertEqual(response.headers[.cacheControl], "max-age=2592000") + } + try await client.execute(uri: filename2, method: .get) { response in + XCTAssertEqual(response.headers[.cacheControl], "max-age=2592000, private") + } + } } } } @@ -306,14 +291,12 @@ final class FileMiddlewareTests: XCTestCase { let app = Application(responder: router.buildResponder()) let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: "index.html") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: "/", method: .get) { response in - XCTAssertEqual(String(buffer: response.body), text) + try await FileIOTests.withFile("index.html", contents: text.utf8) { + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), text) + } } } } @@ -347,33 +330,30 @@ final class FileMiddlewareTests: XCTestCase { let app = Application(responder: router.buildResponder()) let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: "test.html") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - let fileIO = NonBlockingFileIO(threadPool: .singleton) + try await FileIOTests.withFile("test.html", contents: text.utf8) { + try await app.test(.router) { client in + try await client.execute(uri: "/test.html", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), text) + } - try await app.test(.router) { client in - try await client.execute(uri: "/test.html", method: .get) { response in - XCTAssertEqual(String(buffer: response.body), text) - } + try await client.execute(uri: "/", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), "") + } - try await client.execute(uri: "/", method: .get) { response in - XCTAssertEqual(String(buffer: response.body), "") - } + let fileSystem = FileSystem(threadPool: .singleton) + try await fileSystem.createSymbolicLink(at: .init("index.html"), withDestination: .init("test.html")) - try await fileIO.symlink(path: "index.html", to: "test.html") + do { + try await client.execute(uri: "/", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), text) + } - do { - try await client.execute(uri: "/", method: .get) { response in - XCTAssertEqual(String(buffer: response.body), text) + try await fileSystem.removeItem(at: .init("index.html")) + } catch { + try await fileSystem.removeItem(at: .init("index.html")) + throw error } - - try await fileIO.unlink(path: "index.html") - } catch { - try await fileIO.unlink(path: "index.html") - throw error } } } @@ -394,14 +374,12 @@ final class FileMiddlewareTests: XCTestCase { let app = Application(responder: router.buildResponder()) let text = "Test file contents" - let data = Data(text.utf8) - let fileURL = URL(fileURLWithPath: "index.html") - XCTAssertNoThrow(try data.write(to: fileURL)) - defer { XCTAssertNoThrow(try FileManager.default.removeItem(at: fileURL)) } - try await app.test(.router) { client in - try await client.execute(uri: "/", method: .get) { response in - XCTAssertEqual(String(buffer: response.body), text) + try await FileIOTests.withFile("index.html", contents: text.utf8) { + try await app.test(.router) { client in + try await client.execute(uri: "/", method: .get) { response in + XCTAssertEqual(String(buffer: response.body), text) + } } } }