From 930f5cd1e0c3a7a7fa1536a329e1f64e450efe81 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 7 May 2026 06:36:23 -0300 Subject: [PATCH 1/4] feat(storage): add StorageTransferTask, MultipartUploadEngine, and upload API - Add StorageTransferTask with AsyncStream progress events and async value property - Add MultipartUploadEngine actor for standard multipart POST uploads - Add UploadSource type for abstracting Data vs file URL uploads - Update upload() and update() methods to return StorageUploadTask instead of Void - Remove old incompatible integration tests (replaced in later PR) Co-Authored-By: Claude Sonnet 4.5 --- Sources/Storage/MultipartUploadEngine.swift | 281 +++++++++++ Sources/Storage/StorageClient.swift | 5 +- Sources/Storage/StorageError.swift | 40 ++ Sources/Storage/StorageFileAPI.swift | 473 +++++++----------- Sources/Storage/StorageTransferTask.swift | 222 ++++++++ Sources/Storage/Types.swift | 31 +- Sources/Storage/UploadSource.swift | 137 +++++ .../StorageClientIntegrationTests.swift | 85 ---- .../StorageFileIntegrationTests.swift | 410 --------------- .../MultipartUploadEngineTests.swift | 157 ++++++ Tests/StorageTests/StorageFileAPITests.swift | 328 +++--------- .../StorageTransferTaskTests.swift | 182 +++++++ Tests/StorageTests/SupabaseStorageTests.swift | 72 +-- Tests/StorageTests/UploadProgressTests.swift | 10 +- 14 files changed, 1300 insertions(+), 1133 deletions(-) create mode 100644 Sources/Storage/MultipartUploadEngine.swift create mode 100644 Sources/Storage/StorageTransferTask.swift create mode 100644 Sources/Storage/UploadSource.swift delete mode 100644 Tests/IntegrationTests/StorageClientIntegrationTests.swift delete mode 100644 Tests/IntegrationTests/StorageFileIntegrationTests.swift create mode 100644 Tests/StorageTests/MultipartUploadEngineTests.swift create mode 100644 Tests/StorageTests/StorageTransferTaskTests.swift diff --git a/Sources/Storage/MultipartUploadEngine.swift b/Sources/Storage/MultipartUploadEngine.swift new file mode 100644 index 00000000..59004a84 --- /dev/null +++ b/Sources/Storage/MultipartUploadEngine.swift @@ -0,0 +1,281 @@ +// +// MultipartUploadEngine.swift +// Storage +// + +import ConcurrencyExtras +import Foundation +import Helpers + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +private struct MultipartServerResponse: Decodable { + let Key: String + let Id: UUID +} + +actor MultipartUploadEngine { + enum State { + case idle + case uploading + case completed(FileUploadResponse) + case failed(StorageError) + case cancelled + + var isTerminal: Bool { + switch self { + case .completed, .failed, .cancelled: return true + default: return false + } + } + } + + private let bucketId: String + private let path: String + private let source: UploadSource + private let options: FileOptions + private let client: StorageClient + private let eventsContinuation: AsyncStream>.Continuation + private let resultContinuation: AsyncStream>.Continuation + + private var state: State = .idle + private var currentUploadTask: Task? + + init( + bucketId: String, + path: String, + source: UploadSource, + options: FileOptions, + client: StorageClient, + eventsContinuation: AsyncStream>.Continuation, + resultContinuation: AsyncStream>.Continuation + ) { + self.bucketId = bucketId + self.path = path + self.source = source + self.options = options + self.client = client + self.eventsContinuation = eventsContinuation + self.resultContinuation = resultContinuation + } + + func start() { + guard case .idle = state else { return } + state = .uploading + currentUploadTask = Task { await run() } + } + + // Multipart uploads do not support pause/resume — the single-shot request cannot be + // interrupted and resumed mid-flight. These are intentional no-ops; callers that need + // pause/resume should use the TUS upload path instead. + func pause() {} + func resume() {} + + func cancel() { + guard !state.isTerminal else { return } + currentUploadTask?.cancel() + state = .cancelled + let error = StorageError.cancelled + eventsContinuation.yield(.failed(error)) + eventsContinuation.finish() + resultContinuation.yield(.failure(error)) + resultContinuation.finish() + } + + // MARK: - Private + + private func run() async { + do { + try Task.checkCancellation() + let response = try await performUpload() + finish(with: .success(response)) + } catch { + handleError(error) + } + } + + private func performUpload() async throws -> FileUploadResponse { + #if DEBUG + let builder = MultipartBuilder( + boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)" + ) + #else + let builder = MultipartBuilder() + #endif + + let multipart = source.append(to: builder, withPath: path, options: options) + + var headers: [String: String] = [:] + headers["Content-Type"] = multipart.contentType + if options.upsert { + headers["x-upsert"] = "true" + } + + var url = client.url.appendingPathComponent("object").appendingPathComponent(bucketId) + for component in path.split(separator: "/") { + url = url.appendingPathComponent(String(component)) + } + + let request = try await client.http.createRequest( + .post, + url: url, + headers: client.mergedHeaders(headers) + ) + + do { + let (data, urlResponse) = try await uploadWithProgress(request: request, multipart: multipart) + let httpResponse = try client.http.validateResponse(urlResponse, data: data) + client.logResponse(httpResponse, data: data) + let serverResponse = try client.decoder.decode(MultipartServerResponse.self, from: data) + return FileUploadResponse(id: serverResponse.Id, path: path, fullPath: serverResponse.Key) + } catch { + client.logFailure(error) + throw client.translateStorageError(error) + } + } + + private func uploadWithProgress( + request: URLRequest, + multipart: MultipartBuilder + ) async throws -> (Data, URLResponse) { + let progressContinuation = eventsContinuation + + let progressDelegate = UploadProgressDelegate { sent, total in + progressContinuation.yield( + .progress(TransferProgress(bytesTransferred: sent, totalBytes: total)) + ) + } + + if source.usesTempFileUpload { + let tempFile = try multipart.buildToTempFile() + defer { try? FileManager.default.removeItem(at: tempFile) } + #if canImport(Darwin) + return try await client.http.session.upload( + for: request, fromFile: tempFile, delegate: progressDelegate) + #else + let result = try await client.http.session.upload(for: request, fromFile: tempFile) + let totalBytes = (try? source.totalBytes()) ?? 0 + progressContinuation.yield( + .progress(TransferProgress(bytesTransferred: totalBytes, totalBytes: totalBytes)) + ) + return result + #endif + } else { + let body = try multipart.buildInMemory() + #if canImport(Darwin) + return try await client.http.session.upload( + for: request, from: body, delegate: progressDelegate) + #else + let result = try await client.http.session.upload(for: request, from: body) + let totalBytes = Int64(body.count) + progressContinuation.yield( + .progress(TransferProgress(bytesTransferred: totalBytes, totalBytes: totalBytes)) + ) + return result + #endif + } + } + + private func handleError(_ error: any Error) { + let isCancellation = + error is CancellationError || (error as? URLError)?.code == .cancelled + if isCancellation { + switch state { + case .uploading: + cancel() + default: + return + } + } else { + finish(with: .failure(StorageError.from(error))) + } + } + + private func finish(with result: Result) { + switch result { + case .success(let response): + state = .completed(response) + eventsContinuation.yield(.completed(response)) + case .failure(let error): + let storageError = + error as? StorageError ?? StorageError.networkError(underlying: error) + state = .failed(storageError) + eventsContinuation.yield(.failed(storageError)) + } + eventsContinuation.finish() + resultContinuation.yield(result.mapError { $0 }) + resultContinuation.finish() + } +} + +// MARK: - Factory + +extension MultipartUploadEngine { + static func makeTask( + bucketId: String, + path: String, + source: UploadSource, + options: FileOptions, + client: StorageClient + ) -> StorageUploadTask { + let (eventStream, eventsContinuation) = + AsyncStream>.makeStream() + let (resultStream, resultContinuation) = + AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let engine = MultipartUploadEngine( + bucketId: bucketId, + path: path, + source: source, + options: options, + client: client, + eventsContinuation: eventsContinuation, + resultContinuation: resultContinuation + ) + + eventsContinuation.onTermination = { reason in + guard case .cancelled = reason else { return } + Task { await engine.cancel() } + } + + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + let task = StorageUploadTask( + events: eventStream, + resultTask: resultTask, + pause: { await engine.pause() }, + resume: { await engine.resume() }, + cancel: { await engine.cancel() } + ) + + Task { await engine.start() } + + return task + } +} + +// MARK: - Progress delegate + +private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { + let handler: @Sendable (Int64, Int64) -> Void + + init(handler: @Sendable @escaping (Int64, Int64) -> Void) { + self.handler = handler + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + handler(totalBytesSent, totalBytesExpectedToSend) + } +} diff --git a/Sources/Storage/StorageClient.swift b/Sources/Storage/StorageClient.swift index 3d98cecc..1689e59c 100644 --- a/Sources/Storage/StorageClient.swift +++ b/Sources/Storage/StorageClient.swift @@ -114,6 +114,7 @@ public final class StorageClient: Sendable { package let http: _HTTPClient private let usesTokenProvider: Bool + let encoder: JSONEncoder = { let encoder = JSONEncoder.supabase() encoder.keyEncodingStrategy = .convertToSnakeCase @@ -209,6 +210,7 @@ public final class StorageClient: Sendable { session: configuration.session, tokenProvider: tokenProvider ) + } func mergedHeaders(_ headers: [String: String]? = nil) -> [String: String] { @@ -336,6 +338,7 @@ public final class StorageClient: Sendable { configuration.logger?.error("Response: Failure \(error)") } + /// Returns a ``StorageFileAPI`` scoped to the given bucket. /// /// All file operations — upload, download, list, delete, signed URLs — are performed through @@ -348,7 +351,7 @@ public final class StorageClient: Sendable { /// /// ```swift /// let avatarsBucket = storage.from("avatars") - /// let data = try await avatarsBucket.download(path: "user-123/photo.png") + /// let url = try avatarsBucket.getPublicURL(path: "user-123/photo.png") /// ``` public func from(_ id: String) -> StorageFileAPI { StorageFileAPI(bucketId: id, client: self) diff --git a/Sources/Storage/StorageError.swift b/Sources/Storage/StorageError.swift index 4d082bbd..d2695f4d 100644 --- a/Sources/Storage/StorageError.swift +++ b/Sources/Storage/StorageError.swift @@ -179,3 +179,43 @@ extension StorageError: LocalizedError { message } } + +extension StorageErrorCode { + // MARK: - Transfer errors (client-side) + + /// A network error occurred during a transfer (transient; retriable on resume). + public static let networkError = StorageErrorCode("NetworkError") + /// A file system operation (move or read) failed during a transfer. + public static let fileSystemError = StorageErrorCode("FileSystemError") + /// The transfer was explicitly cancelled or the enclosing Swift Task was cancelled. + public static let cancelled = StorageErrorCode("Cancelled") +} + +extension StorageError { + static func networkError(underlying: any Error) -> StorageError { + StorageError(message: underlying.localizedDescription, errorCode: .networkError) + } + + static func fileSystemError(underlying: any Error) -> StorageError { + StorageError(message: underlying.localizedDescription, errorCode: .fileSystemError) + } + + static let cancelled = StorageError( + message: "Transfer was cancelled", + errorCode: .cancelled + ) + + /// Converts any `Error` to a `StorageError`. + /// + /// - Returns `self` when `error` is already a `StorageError`. + /// - Returns ``StorageError/cancelled`` when `error` is a `CancellationError` or + /// a `URLError` with code `.cancelled`. + /// - Otherwise wraps `error` as ``StorageError/networkError(underlying:)``. + static func from(_ error: any Error) -> StorageError { + if let storageError = error as? StorageError { return storageError } + if error is CancellationError || (error as? URLError)?.code == .cancelled { + return .cancelled + } + return .networkError(underlying: error) + } +} diff --git a/Sources/Storage/StorageFileAPI.swift b/Sources/Storage/StorageFileAPI.swift index 7a47bf19..c7c62b95 100644 --- a/Sources/Storage/StorageFileAPI.swift +++ b/Sources/Storage/StorageFileAPI.swift @@ -21,71 +21,6 @@ let defaultFileOptions = FileOptions( upsert: false ) -enum FileUpload { - case data(Data) - case url(URL) - - func append( - to builder: MultipartBuilder, - withPath path: String, - options: FileOptions - ) -> MultipartBuilder { - var builder = builder.addText( - name: "cacheControl", - value: options.cacheControl - ) - - if let metadata = options.metadata { - builder = builder.addText( - name: "metadata", - value: String(data: encodeMetadata(metadata), encoding: .utf8) ?? "" - ) - } - - switch self { - case .data(let data): - return builder.addData( - name: "", - data: data, - fileName: path.fileName, - mimeType: options.contentType - ?? mimeType(forPathExtension: path.pathExtension) - ) - - case .url(let url): - return builder.addFile( - name: "", - fileURL: url, - fileName: url.lastPathComponent, - mimeType: options.contentType - ?? mimeType(forPathExtension: url.pathExtension) - ) - } - } - - var usesTempFileUpload: Bool { - get throws { - guard case .url(let url) = self else { return false } - - let fileSize = - try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 - return fileSize >= 10 * 1024 * 1024 - } - } - - func defaultOptions() -> FileOptions { - switch self { - case .data: - return defaultFileOptions - - case .url: - var options = defaultFileOptions - options.contentType = nil - return options - } - } -} - #if DEBUG import ConcurrencyExtras let testingBoundary = LockIsolated(nil) @@ -106,8 +41,10 @@ enum FileUpload { /// // Upload /// let response = try await bucket.upload("user-123/photo.png", data: imageData) /// -/// // Download -/// let data = try await bucket.download(path: "user-123/photo.png") +/// // Download to disk +/// let url = try await bucket.download(path: "user-123/photo.png").value +/// // Or download into memory +/// let data = try await bucket.downloadData(path: "user-123/photo.png").value /// /// // Public URL (public bucket) /// let url = try bucket.getPublicURL(path: "user-123/photo.png") @@ -160,202 +97,129 @@ public struct StorageFileAPI: Sendable { static let xUpsert = "x-upsert" } - private struct UploadResponse: Decodable { - let Key: String - let Id: UUID - } - private struct SignedUploadResponse: Decodable { let Key: String } - private func _uploadOrUpdate( - method: HTTPMethod, - path: String, - file: FileUpload, - options: FileOptions?, - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> FileUploadResponse { - let options = options ?? defaultFileOptions - let cleanPath = _removeEmptyFolders(path) - let _path = _getFinalPath(cleanPath) - - var headers = multipartHeaders(options: options) - headers[Header.xUpsert] = "\(options.upsert)" - - let response: UploadResponse = try await uploadMultipart( - method, - url: client.url.appendingPathComponent("object/\(_path)"), - path: path, - file: file, - options: options, - headers: headers, - progress: progress - ) - - return FileUploadResponse( - id: response.Id, - path: path, - fullPath: response.Key - ) - } /// Uploads a `Data` value to an existing bucket. /// - /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error - /// is returned. Set `upsert: true` to overwrite silently instead. - /// /// - Parameters: /// - path: The destination path within the bucket, e.g. `"folder/image.png"`. /// The bucket must already exist. /// - data: The raw file bytes to store. /// - options: Upload options such as content type, cache duration, and upsert behaviour. - /// - Returns: A ``FileUploadResponse`` containing the assigned storage ID and full path. - /// - Throws: ``StorageError`` if the path already exists (when `upsert` is `false`), the - /// bucket does not exist, or the request otherwise fails. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. + /// + /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error + /// is returned. Set `upsert: true` to overwrite silently instead. /// /// ## Example /// /// ```swift - /// let imageData = UIImage(named: "photo")!.jpegData(compressionQuality: 0.8)! /// let response = try await storage.from("avatars").upload( /// "user-123/photo.jpg", /// data: imageData, - /// options: FileOptions(contentType: "image/jpeg", upsert: true) - /// ) - /// print(response.fullPath) // "avatars/user-123/photo.jpg" + /// options: FileOptions(contentType: "image/jpeg") + /// ).value /// ``` @discardableResult public func upload( _ path: String, data: Data, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> FileUploadResponse { - try await _uploadOrUpdate( - method: .post, - path: path, - file: .data(data), - options: options, - progress: progress - ) + options: FileOptions = FileOptions() + ) -> StorageUploadTask { + return MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .data(data), options: options, client: client) } /// Uploads a file from a local `URL` to an existing bucket. /// - /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid - /// loading the entire payload into memory. - /// /// - Parameters: /// - path: The destination path within the bucket, e.g. `"folder/image.png"`. /// The bucket must already exist. /// - fileURL: A local `file://` URL pointing to the file to upload. /// - options: Upload options such as content type, cache duration, and upsert behaviour. /// When `contentType` is `nil`, the MIME type is inferred from the file extension. - /// - Returns: A ``FileUploadResponse`` containing the assigned storage ID and full path. - /// - Throws: ``StorageError`` if the path already exists (when `upsert` is `false`), the - /// bucket does not exist, or the request otherwise fails. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. /// /// ## Example /// /// ```swift - /// let fileURL = URL(fileURLWithPath: "/tmp/report.pdf") /// let response = try await storage.from("documents").upload( /// "reports/2024/annual.pdf", /// fileURL: fileURL - /// ) + /// ).value /// ``` @discardableResult public func upload( _ path: String, fileURL: URL, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> FileUploadResponse { - try await _uploadOrUpdate( - method: .post, - path: path, - file: .url(fileURL), - options: options, - progress: progress - ) + options: FileOptions = FileOptions() + ) -> StorageUploadTask { + return MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client) } /// Replaces an existing file at the specified path with new `Data`. /// - /// Unlike ``upload(_:data:options:)``, this method always overwrites the existing object and - /// returns an error if no object exists at the given path. + /// Always sets `upsert: true` to overwrite the existing object. /// /// - Parameters: /// - path: The path of the file to replace, e.g. `"folder/image.png"`. - /// The bucket must already exist. /// - data: The new raw file bytes. /// - options: Upload options such as content type and cache duration. - /// - Returns: A ``FileUploadResponse`` containing the storage ID and full path of the updated object. - /// - Throws: ``StorageError`` if the path does not exist or the request fails. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. /// /// ## Example /// /// ```swift - /// let newImageData = UIImage(named: "updated-photo")!.pngData()! /// let response = try await storage.from("avatars").update( /// "user-123/photo.png", /// data: newImageData, /// options: FileOptions(contentType: "image/png") - /// ) + /// ).value /// ``` @discardableResult public func update( _ path: String, data: Data, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> FileUploadResponse { - try await _uploadOrUpdate( - method: .put, - path: path, - file: .data(data), - options: options, - progress: progress - ) + options: FileOptions = FileOptions() + ) -> StorageUploadTask { + var upsertOptions = options + upsertOptions.upsert = true + return upload(path, data: data, options: upsertOptions) } /// Replaces an existing file at the specified path with the contents of a local `URL`. /// - /// Unlike ``upload(_:fileURL:options:)``, this method always overwrites the existing object and - /// returns an error if no object exists at the given path. + /// Always sets `upsert: true` to overwrite the existing object. /// /// - Parameters: /// - path: The path of the file to replace, e.g. `"folder/image.png"`. - /// The bucket must already exist. /// - fileURL: A local `file://` URL pointing to the replacement file. /// - options: Upload options such as content type and cache duration. /// When `contentType` is `nil`, the MIME type is inferred from the file extension. - /// - Returns: A ``FileUploadResponse`` containing the storage ID and full path of the updated object. - /// - Throws: ``StorageError`` if the path does not exist or the request fails. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. /// /// ## Example /// /// ```swift - /// let newFileURL = URL(fileURLWithPath: "/tmp/updated-report.pdf") - /// try await storage.from("documents").update("reports/2024/annual.pdf", fileURL: newFileURL) + /// try await storage.from("documents").update( + /// "reports/2024/annual.pdf", + /// fileURL: newFileURL + /// ).value /// ``` @discardableResult public func update( _ path: String, fileURL: URL, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> FileUploadResponse { - try await _uploadOrUpdate( - method: .put, - path: path, - file: .url(fileURL), - options: options, - progress: progress - ) + options: FileOptions = FileOptions() + ) -> StorageUploadTask { + var upsertOptions = options + upsertOptions.upsert = true + return upload(path, fileURL: fileURL, options: upsertOptions) } - /// Moves an existing file to a new path within the same or a different bucket. /// /// The source file is removed after the move completes. To keep the original, use ``copy(from:to:options:)`` instead. @@ -447,14 +311,33 @@ public struct StorageFileAPI: Sendable { /// Creates a signed URL that grants time-limited access to a private file. /// + /// Signed URLs can be shared with unauthenticated users. They expire after `expiresIn` and + /// cannot be revoked once issued, so avoid long expiration windows for sensitive files. + /// /// - Parameters: /// - path: The file path within the bucket, e.g. `"folder/image.png"`. - /// - expiresIn: How long until the signed URL expires, e.g. `.seconds(3600)`. + /// - expiresIn: How long until the signed URL expires, e.g. `.seconds(3600)` for one hour. /// - download: When non-`nil`, the browser treats the URL as a file download. /// - transform: Optional on-the-fly image transformation applied before the file is served. /// - cacheNonce: An opaque string appended as a `cacheNonce` query parameter. /// - Returns: A signed `URL` ready to be shared or embedded. /// - Throws: ``StorageError`` if the file does not exist or the request fails. + /// + /// ## Example + /// + /// ```swift + /// // Short-lived link for streaming a private video + /// let url = try await storage.from("media") + /// .createSignedURL(path: "user-123/video.mp4", expiresIn: .seconds(3600)) + /// + /// // Signed download link with a custom filename + /// let downloadURL = try await storage.from("reports") + /// .createSignedURL( + /// path: "q4-2024.pdf", + /// expiresIn: .seconds(300), + /// download: .named("Q4-2024-Report.pdf") + /// ) + /// ``` public func createSignedURL( path: String, expiresIn: Duration, @@ -496,6 +379,24 @@ public struct StorageFileAPI: Sendable { /// - cacheNonce: An opaque string appended as a `cacheNonce` query parameter. /// - Returns: An array of ``SignedURLResult`` values, one per input path. /// - Throws: ``StorageError`` if the batch request itself fails. + /// + /// ## Example + /// + /// ```swift + /// let results = try await storage.from("media").createSignedURLs( + /// paths: ["photo1.jpg", "photo2.jpg", "missing.jpg"], + /// expiresIn: .seconds(3600) + /// ) + /// + /// for result in results { + /// switch result { + /// case .success(let path, let url): + /// print("\(path) → \(url)") + /// case .failure(let path, let error): + /// print("\(path) failed: \(error)") + /// } + /// } + /// ``` public func createSignedURLs( paths: [String], expiresIn: Duration, @@ -638,63 +539,6 @@ public struct StorageFileAPI: Sendable { body: .data(originalEncoder.encode(options)) ) } - - /// Downloads a file from the bucket and returns its raw bytes. - /// - /// Use this method for files in private buckets. For public buckets, construct a URL with - /// ``getPublicURL(path:download:options:cacheNonce:)`` and fetch it directly instead — - /// it avoids routing the bytes through the SDK. - /// - /// - Parameters: - /// - path: The path of the file to download, e.g. `"folder/image.png"`. - /// - options: Optional on-the-fly image transformation applied before the file is served. - /// When non-`nil` and non-empty, the request is routed through the image rendering pipeline. - /// - additionalQueryItems: Extra URL query parameters appended to the download request. - /// - cacheNonce: An opaque string appended as a `cacheNonce` query parameter for cache - /// invalidation. - /// - Returns: The raw file data. - /// - Throws: ``StorageError`` if the file does not exist or the request fails. - /// - /// ## Example - /// - /// ```swift - /// // Download raw file - /// let data = try await storage.from("private-docs").download(path: "user-123/report.pdf") - /// - /// // Download with image transformation - /// let thumbnail = try await storage.from("photos").download( - /// path: "gallery/hero.jpg", - /// options: TransformOptions(width: 100, height: 100, resize: .cover) - /// ) - /// ``` - @discardableResult - public func download( - path: String, - options: TransformOptions? = nil, - query additionalQueryItems: [URLQueryItem]? = nil, - cacheNonce: String? = nil - ) async throws -> Data { - var queryItems = options?.queryItems ?? [] - let renderPath = - options.map { !$0.isEmpty } == true - ? "render/image/authenticated" : "object" - let _path = _getFinalPath(path) - - if let additionalQueryItems { - queryItems.append(contentsOf: additionalQueryItems) - } - - if let cacheNonce { - queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce)) - } - - let (data, _) = try await client.fetchData( - .get, - url: storageURL(path: "\(renderPath)/\(_path)", queryItems: queryItems) - ) - return data - } - /// Retrieves extended metadata for a file without downloading its contents. /// /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other @@ -748,15 +592,29 @@ public struct StorageFileAPI: Sendable { /// Returns the public URL for a file in a public bucket. /// - /// The URL is constructed locally without a network request. + /// The bucket must be configured as public; for private buckets use + /// ``createSignedURL(path:expiresIn:download:transform:cacheNonce:)`` instead. /// /// - Parameters: - /// - path: The path of the file within the bucket. + /// - path: The path of the file within the bucket, e.g. `"user-123/avatar.png"`. /// - download: When non-`nil`, the browser treats the URL as a file download. - /// - options: Optional on-the-fly image transformation. + /// - options: Optional on-the-fly image transformation (resize, reformat, quality). /// - cacheNonce: An opaque string appended as a `cacheNonce` query parameter. /// - Returns: The public `URL` for the file. /// - Throws: `URLError(.badURL)` if the resulting URL cannot be constructed. + /// + /// ## Example + /// + /// ```swift + /// // Direct embed URL + /// let url = try storage.from("avatars").getPublicURL(path: "user-123/photo.png") + /// + /// // 200×200 thumbnail in WebP + /// let thumbURL = try storage.from("avatars").getPublicURL( + /// path: "user-123/photo.png", + /// options: TransformOptions(width: 200, height: 200, format: .webp) + /// ) + /// ``` public func getPublicURL( path: String, download: DownloadBehavior? = nil, @@ -890,8 +748,8 @@ public struct StorageFileAPI: Sendable { /// - token: The upload token from ``SignedUploadURL/token``. /// - data: The raw file bytes to store. /// - options: Upload options such as content type and cache duration. - /// - Returns: A ``SignedURLUploadResponse`` containing the path and full storage key. - /// - Throws: ``StorageError`` if the token is invalid or expired, or if the request fails. + /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse`` + /// result, or observed for progress. /// /// ## Example /// @@ -902,7 +760,7 @@ public struct StorageFileAPI: Sendable { /// token: signed.token, /// data: pdfData, /// options: FileOptions(contentType: "application/pdf") - /// ) + /// ).value /// print(response.fullPath) /// ``` @discardableResult @@ -910,16 +768,16 @@ public struct StorageFileAPI: Sendable { _ path: String, token: String, data: Data, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> SignedURLUploadResponse { - try await _uploadToSignedURL( - path: path, - token: token, - file: .data(data), - options: options, - progress: progress - ) + options: FileOptions = FileOptions() + ) -> StorageTransferTask { + makeSignedURLUploadTask { + try await self._uploadToSignedURL( + path: path, + token: token, + file: .data(data), + options: options + ) + } } /// Uploads a file from a local `URL` to a path using a token obtained from @@ -935,8 +793,8 @@ public struct StorageFileAPI: Sendable { /// - fileURL: A local `file://` URL pointing to the file to upload. /// - options: Upload options such as content type and cache duration. /// When `contentType` is `nil`, the MIME type is inferred from the file extension. - /// - Returns: A ``SignedURLUploadResponse`` containing the path and full storage key. - /// - Throws: ``StorageError`` if the token is invalid or expired, or if the request fails. + /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse`` + /// result, or observed for progress. /// /// ## Example /// @@ -947,36 +805,74 @@ public struct StorageFileAPI: Sendable { /// signed.path, /// token: signed.token, /// fileURL: fileURL - /// ) + /// ).value /// ``` @discardableResult public func uploadToSignedURL( _ path: String, token: String, fileURL: URL, - options: FileOptions = FileOptions(), - progress: (@Sendable (UploadProgress) -> Void)? = nil - ) async throws -> SignedURLUploadResponse { - try await _uploadToSignedURL( - path: path, - token: token, - file: .url(fileURL), - options: options, - progress: progress + options: FileOptions = FileOptions() + ) -> StorageTransferTask { + makeSignedURLUploadTask { + try await self._uploadToSignedURL( + path: path, + token: token, + file: .fileURL(fileURL), + options: options + ) + } + } + + private func makeSignedURLUploadTask( + _ body: @Sendable @escaping () async throws -> SignedURLUploadResponse + ) -> StorageTransferTask { + let (eventStream, eventsContinuation) = + AsyncStream>.makeStream() + let (resultStream, resultContinuation) = + AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + let uploadTask = Task { + do { + let response = try await body() + eventsContinuation.yield(.completed(response)) + eventsContinuation.finish() + resultContinuation.yield(.success(response)) + resultContinuation.finish() + } catch { + let storageError = StorageError.from(error) + eventsContinuation.yield(.failed(storageError)) + eventsContinuation.finish() + resultContinuation.yield(.failure(storageError)) + resultContinuation.finish() + } + } + + return StorageTransferTask( + events: eventStream, + resultTask: resultTask, + pause: {}, + resume: {}, + cancel: { uploadTask.cancel() } ) } private func _uploadToSignedURL( path: String, token: String, - file: FileUpload, - options: FileOptions, - progress: (@Sendable (UploadProgress) -> Void)? = nil + file: UploadSource, + options: FileOptions ) async throws -> SignedURLUploadResponse { var headers = multipartHeaders(options: options) headers[Header.xUpsert] = "\(options.upsert)" - let response: SignedUploadResponse = try await uploadMultipart( + let response: SignedUploadResponse = try await _performMultipartRequest( .put, url: storageURL( path: "object/upload/sign/\(bucketId)/\(path)", @@ -985,21 +881,19 @@ public struct StorageFileAPI: Sendable { path: path, file: file, options: options, - headers: headers, - progress: progress + headers: headers ) return SignedURLUploadResponse(path: path, fullPath: response.Key) } - private func uploadMultipart( + private func _performMultipartRequest( _ method: HTTPMethod, url: URL, path: String, - file: FileUpload, + file: UploadSource, options: FileOptions, - headers: [String: String], - progress: (@Sendable (UploadProgress) -> Void)? = nil + headers: [String: String] ) async throws -> Response { #if DEBUG let builder = MultipartBuilder( @@ -1024,27 +918,23 @@ public struct StorageFileAPI: Sendable { headers: client.mergedHeaders(headers) ) - let delegate = progress.map { UploadProgressDelegate(onProgress: $0) } - do { client.logRequest(method, url: url) let data: Data let response: URLResponse - if try file.usesTempFileUpload { + if file.usesTempFileUpload { let tempFile = try multipart.buildToTempFile() defer { try? FileManager.default.removeItem(at: tempFile) } (data, response) = try await client.http.session.upload( for: request, - fromFile: tempFile, - delegate: delegate + fromFile: tempFile ) } else { (data, response) = try await client.http.session.upload( for: request, - from: try multipart.buildInMemory(), - delegate: delegate + from: try multipart.buildInMemory() ) } @@ -1087,33 +977,6 @@ public struct StorageFileAPI: Sendable { } } -// MARK: - Upload progress - -private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, - @unchecked Sendable -{ - private let onProgress: @Sendable (UploadProgress) -> Void - - init(onProgress: @escaping @Sendable (UploadProgress) -> Void) { - self.onProgress = onProgress - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didSendBodyData bytesSent: Int64, - totalBytesSent: Int64, - totalBytesExpectedToSend: Int64 - ) { - onProgress( - UploadProgress( - totalBytesSent: totalBytesSent, - totalBytesExpectedToSend: totalBytesExpectedToSend - ) - ) - } -} - func _removeEmptyFolders(_ path: String) -> String { let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let cleanedPath = trimmedPath.replacingOccurrences( diff --git a/Sources/Storage/StorageTransferTask.swift b/Sources/Storage/StorageTransferTask.swift new file mode 100644 index 00000000..cb1147d6 --- /dev/null +++ b/Sources/Storage/StorageTransferTask.swift @@ -0,0 +1,222 @@ +// +// StorageTransferTask.swift +// Storage +// +// Created by Guilherme Souza on 04/05/26. +// + +import Foundation + +/// A handle to an in-flight upload or download. +/// +/// ## Usage patterns +/// +/// **Fire and forget** +/// ```swift +/// storage.from("avatars").upload("user.jpg", data: imageData) +/// ``` +/// +/// **Await the result** +/// ```swift +/// let response = try await storage.from("avatars") +/// .upload("user.jpg", data: imageData) +/// .value +/// ``` +/// +/// **Observe progress** +/// ```swift +/// let task = storage.from("avatars").upload("user.jpg", data: imageData) +/// for await event in task.events { +/// switch event { +/// case .progress(let p): +/// updateProgressBar(p.fractionCompleted) +/// case .completed(let response): +/// print("Uploaded to \(response.fullPath)") +/// case .failed(let error): +/// print("Upload failed: \(error.message)") +/// } +/// } +/// ``` +/// +/// **Pause, resume, and cancel (TUS uploads only)** +/// ```swift +/// // TUS (resumable) upload — use method: .resumable to force TUS +/// let upload = storage.from("videos").upload("clip.mp4", fileURL: fileURL, method: .resumable) +/// await upload.pause() // suspend mid-upload +/// await upload.resume() // continue from where it left off +/// await upload.cancel() // abort entirely +/// +/// // Download +/// let download = storage.from("videos").download(path: "clip.mp4") +/// await download.pause() // suspend; captures resume data if server supports range requests +/// await download.resume() // continue from last byte, or restart if no resume data +/// await download.cancel() // abort entirely +/// ``` +/// +/// Both ``events`` and ``value`` are independent: consuming one does not affect the other. +public final class StorageTransferTask: Sendable { + + /// A stream of transfer lifecycle events. + /// + /// Emits ``TransferEvent/progress(_:)`` events while the transfer is active, then terminates + /// with either ``TransferEvent/completed(_:)`` or ``TransferEvent/failed(_:)``. + public let events: AsyncStream> + + private let _resultTask: Task + private let _pause: @Sendable () async -> Void + private let _resume: @Sendable () async -> Void + private let _cancel: @Sendable () async -> Void + + init( + events: AsyncStream>, + resultTask: Task, + pause: @Sendable @escaping () async -> Void, + resume: @Sendable @escaping () async -> Void, + cancel: @Sendable @escaping () async -> Void + ) { + self.events = events + self._resultTask = resultTask + self._pause = pause + self._resume = resume + self._cancel = cancel + } + + /// The transfer outcome as a `Result`. Never throws — inspect `.success` / `.failure` directly. + public var result: Result { + get async { await _resultTask.result } + } + + /// Awaits the success value. Throws `StorageError` on failure or cancellation. + public var value: Success { + get async throws { try await result.get() } + } + + /// Suspends the transfer. + /// + /// Only supported for TUS (resumable) uploads. For multipart uploads this is a no-op — + /// use ``cancel()`` and re-upload from scratch if you need to stop a multipart transfer. + /// For TUS uploads the current in-flight chunk is drained before the task suspends. + public func pause() async { await _pause() } + + /// Resumes a previously paused transfer. + /// + /// Only supported for TUS (resumable) uploads. For multipart uploads this is a no-op. + /// For TUS uploads the server is HEAD-queried to re-sync the byte offset before uploading resumes. + public func resume() async { await _resume() } + + /// Cancels the transfer immediately. + /// + /// Cancelling a transfer that has already completed or failed is a no-op. + /// After cancellation, ``value`` throws a ``StorageError`` with code ``StorageErrorCode/cancelled``, + /// and the ``events`` stream ends with a ``TransferEvent/failed(_:)`` event. + public func cancel() async { + // Order is load-bearing: _cancel() must run first. + // It sets the engine's state to .cancelled and finishes the continuations before + // Swift's structured cancellation propagates. If _resultTask.cancel() fired first, + // the resulting CancellationError would reach handleRunError while the engine is + // still in .uploading/.creating, causing it to call cancel() a second time and + // race with this explicit cancellation path. + await _cancel() + _resultTask.cancel() + } +} + +extension StorageTransferTask { + /// Returns a new task that applies `transform` to the success value. + /// Progress events pass through unchanged. Pause/resume/cancel delegate to `self`. + func mapResult( + _ transform: @Sendable @escaping (Success) throws -> NewSuccess + ) -> StorageTransferTask { + let (newStream, newContinuation) = AsyncStream>.makeStream() + let (resultStream, resultContinuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let bridgeTask = Task { + for await event in self.events { + switch event { + case .progress(let p): + newContinuation.yield(.progress(p)) + case .completed(let value): + do { + let mapped = try transform(value) + newContinuation.yield(.completed(mapped)) + newContinuation.finish() + resultContinuation.yield(.success(mapped)) + resultContinuation.finish() + } catch { + let storageError = StorageError.fileSystemError(underlying: error) + newContinuation.yield(.failed(storageError)) + newContinuation.finish() + resultContinuation.yield(.failure(storageError)) + resultContinuation.finish() + } + case .failed(let error): + newContinuation.yield(.failed(error)) + newContinuation.finish() + resultContinuation.yield(.failure(error)) + resultContinuation.finish() + } + } + newContinuation.finish() + } + + let newResultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + return StorageTransferTask( + events: newStream, + resultTask: newResultTask, + pause: self._pause, + resume: self._resume, + cancel: { + await self._cancel() + bridgeTask.cancel() + // Ensure resultContinuation is finished so newResultTask exits via the stream + // (yielding StorageError.cancelled) rather than via Task cancellation + // (which would throw CancellationError), keeping the error type deterministic. + resultContinuation.finish() + } + ) + } +} + +/// An event emitted during a transfer. +public enum TransferEvent: Sendable { + /// Periodic progress update during an active transfer. + case progress(TransferProgress) + /// The transfer finished successfully. Terminal — the stream ends after this event. + case completed(Success) + /// The transfer failed or was cancelled. Terminal — the stream ends after this event. + case failed(StorageError) +} + +/// Byte-level progress for a transfer. +public struct TransferProgress: Sendable { + /// Number of bytes sent or received so far. + public let bytesTransferred: Int64 + + /// Total size of the transfer in bytes. + public let totalBytes: Int64 + + /// Transfer completion as a value between `0.0` and `1.0`. + /// Returns `0` when `totalBytes` is zero. + public var fractionCompleted: Double { + guard totalBytes > 0 else { return 0 } + return Double(bytesTransferred) / Double(totalBytes) + } +} + +/// A handle for an upload. +/// +/// The success value is a ``FileUploadResponse`` containing the storage path and object ID. +/// TUS uploads support ``StorageTransferTask/pause()``, ``StorageTransferTask/resume()``, and +/// ``StorageTransferTask/cancel()``; multipart uploads only support cancel. +public typealias StorageUploadTask = StorageTransferTask + +/// A handle for a download to disk. +/// +/// The success value is a `URL` pointing to a temporary file on disk. +/// Move or read the file before the app exits — it is not guaranteed to persist across launches. +public typealias StorageDownloadTask = StorageTransferTask diff --git a/Sources/Storage/Types.swift b/Sources/Storage/Types.swift index fb2c186e..fd20797b 100644 --- a/Sources/Storage/Types.swift +++ b/Sources/Storage/Types.swift @@ -1,6 +1,7 @@ import Foundation import Helpers + /// Parameters used to filter and paginate results from ``StorageFileAPI/list(path:options:)``. /// /// All fields are optional; omitted fields fall back to server-side defaults (100 items per page, @@ -204,34 +205,6 @@ public struct FileUploadResponse: Sendable { public let fullPath: String } -/// Reports upload progress for a file upload operation. -/// -/// Passed to the `progress` closure on upload methods such as -/// ``StorageFileAPI/upload(_:data:options:progress:)``. -/// -/// ## Example -/// -/// ```swift -/// try await bucket.upload("video.mp4", fileURL: localURL) { progress in -/// print("\(Int(progress.fractionCompleted * 100))%") -/// } -/// ``` -public struct UploadProgress: Sendable { - /// The total number of bytes sent so far. - public let totalBytesSent: Int64 - - /// The total number of bytes expected to be sent. - public let totalBytesExpectedToSend: Int64 - - /// Upload completion fraction, from `0.0` to `1.0`. - /// - /// Returns `0.0` when `totalBytesExpectedToSend` is zero. - public var fractionCompleted: Double { - guard totalBytesExpectedToSend > 0 else { return 0 } - return Double(totalBytesSent) / Double(totalBytesExpectedToSend) - } -} - /// The server's response after a successful upload via a signed upload URL. /// /// Returned by ``StorageFileAPI/uploadToSignedURL(_:token:data:options:)`` and @@ -675,7 +648,7 @@ public enum DownloadBehavior: Sendable { /// Options for on-the-fly image transformation via the Supabase Storage image transformation API. /// /// Use `TransformOptions` when calling -/// ``StorageFileAPI/download(path:options:query:cacheNonce:)`` or +/// ``StorageFileAPI/download(path:options:)`` or /// ``StorageFileAPI/getPublicURL(path:download:options:cacheNonce:)`` to resize, reformat, or /// adjust the quality of images before they are served to the client. /// diff --git a/Sources/Storage/UploadSource.swift b/Sources/Storage/UploadSource.swift new file mode 100644 index 00000000..10a64da5 --- /dev/null +++ b/Sources/Storage/UploadSource.swift @@ -0,0 +1,137 @@ +// +// UploadSource.swift +// Storage +// + +import Foundation +import Helpers + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A `FileHandle` wrapper that closes the underlying handle in `deinit`. +/// +/// Guarantees the file descriptor is released regardless of how the owning scope +/// exits — normal return, `throw`, or cooperative Task cancellation. +final class AutoreleasingFileHandle { + private let handle: FileHandle + + init(forReadingFrom url: URL) throws { + handle = try FileHandle(forReadingFrom: url) + } + + func seek(toOffset offset: UInt64) throws { + try handle.seek(toOffset: offset) + } + + func read(upToCount count: Int) throws -> Data { + try handle.read(upToCount: count) ?? Data() + } + + deinit { + try? handle.close() + } +} + +enum UploadSource: Sendable { + case data(Data) + case fileURL(URL) + + // MARK: - TUS chunked streaming + + func totalBytes() throws -> Int64 { + switch self { + case .data(let d): + return Int64(d.count) + case .fileURL(let url): + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + guard let size = attrs[.size] as? Int64 else { + throw StorageError(message: "Cannot determine file size", errorCode: .unknown) + } + return size + } + } + + /// Opens an `AutoreleasingFileHandle` for reading the file at `.fileURL`, or returns `nil` for `.data`. + /// The handle closes itself in `deinit` — no manual cleanup required. + func openForReading() throws -> AutoreleasingFileHandle? { + guard case .fileURL(let url) = self else { return nil } + return try AutoreleasingFileHandle(forReadingFrom: url) + } + + /// Reads a chunk from the source. + /// + /// Pass a pre-opened `fileHandle` (from ``openForReading()``) to avoid re-opening the file + /// on every chunk. When `fileHandle` is `nil` and the source is `.fileURL`, a temporary + /// `AutoreleasingFileHandle` is created for this call only and released (closed) on return. + func readChunk(at offset: Int64, maxSize: Int, fileHandle: AutoreleasingFileHandle? = nil) + throws -> Data + { + switch self { + case .data(let d): + let start = Int(offset) + let end = min(start + maxSize, d.count) + return d[start.. MultipartBuilder { + var builder = builder.addText(name: "cacheControl", value: options.cacheControl) + + if let metadata = options.metadata { + builder = builder.addText( + name: "metadata", + value: String(data: encodeMetadata(metadata), encoding: .utf8) ?? "" + ) + } + + switch self { + case .data(let data): + return builder.addData( + name: "", + data: data, + fileName: path.fileName, + mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension) + ) + case .fileURL(let url): + return builder.addFile( + name: "", + fileURL: url, + fileName: url.lastPathComponent, + mimeType: options.contentType ?? mimeType(forPathExtension: url.pathExtension) + ) + } + } + + var usesTempFileUpload: Bool { + guard case .fileURL(let url) = self else { return false } + // If the file size cannot be determined (symlink, network-mounted path, etc.) fall back + // to true — streaming via a temp file is safe regardless of size, and avoids loading an + // unknown-size file entirely into memory. Matches the conservative Int.max fallback used + // in the smart-default TUS-vs-multipart routing in StorageFileAPI. + let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? Int.max + return fileSize >= 10 * 1024 * 1024 + } + + func defaultOptions() -> FileOptions { + switch self { + case .data: + return defaultFileOptions + case .fileURL: + var options = defaultFileOptions + options.contentType = nil + return options + } + } +} diff --git a/Tests/IntegrationTests/StorageClientIntegrationTests.swift b/Tests/IntegrationTests/StorageClientIntegrationTests.swift deleted file mode 100644 index 3078e5be..00000000 --- a/Tests/IntegrationTests/StorageClientIntegrationTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StorageClientIntegrationTests.swift -// -// -// Created by Guilherme Souza on 07/05/24. -// - -import Storage -import XCTest - -final class StorageClientIntegrationTests: XCTestCase { - let storage = StorageClient( - url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, - configuration: StorageClientConfiguration( - headers: [ - "Authorization": "Bearer \(DotEnv.SUPABASE_SECRET_KEY)" - ], - logger: nil - ) - ) - - override func setUp() async throws { - try await super.setUp() - - try XCTSkipUnless( - ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil, - "INTEGRATION_TESTS not defined." - ) - - // Clean up test-bucket if it exists from a previous failed run - // to make tests idempotent - let testBucketName = "test-bucket" - do { - // First empty the bucket (required before deletion) - let files = try await storage.from(testBucketName).list() - if !files.isEmpty { - let filePaths = files.map { $0.name } - try await storage.from(testBucketName).remove(paths: filePaths) - } - try await storage.deleteBucket(testBucketName) - } catch { - // Ignore errors - bucket may not exist, which is expected - } - } - - func testBucket_CRUD() async throws { - let bucketName = "test-bucket" - - var buckets = try await storage.listBuckets() - XCTAssertFalse(buckets.contains(where: { $0.name == bucketName })) - - try await storage.createBucket(bucketName, options: .init(isPublic: true)) - - var bucket = try await storage.getBucket(bucketName) - XCTAssertEqual(bucket.name, bucketName) - XCTAssertEqual(bucket.id, bucketName) - XCTAssertEqual(bucket.isPublic, true) - - buckets = try await storage.listBuckets() - XCTAssertTrue(buckets.contains { $0.id == bucket.id }) - - try await storage.updateBucket( - bucketName, options: BucketOptions(allowedMimeTypes: ["image/jpeg"])) - - bucket = try await storage.getBucket(bucketName) - XCTAssertEqual(bucket.allowedMimeTypes, ["image/jpeg"]) - - try await storage.deleteBucket(bucketName) - - buckets = try await storage.listBuckets() - XCTAssertFalse(buckets.contains { $0.id == bucket.id }) - } - - func testGetBucketWithWrongId() async { - do { - _ = try await storage.getBucket("not-exist-id") - XCTFail("Unexpected success") - } catch let error as StorageError { - XCTAssertEqual(error.statusCode, 404) - XCTAssertEqual(error.message, "Bucket not found") - } catch { - XCTFail("Unexpected error type: \(error)") - } - } -} diff --git a/Tests/IntegrationTests/StorageFileIntegrationTests.swift b/Tests/IntegrationTests/StorageFileIntegrationTests.swift deleted file mode 100644 index 95a6ded5..00000000 --- a/Tests/IntegrationTests/StorageFileIntegrationTests.swift +++ /dev/null @@ -1,410 +0,0 @@ -// -// StorageFileIntegrationTests.swift -// -// -// Created by Guilherme Souza on 07/05/24. -// - -import InlineSnapshotTesting -import Storage -import XCTest - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -final class StorageFileIntegrationTests: XCTestCase { - let storage = StorageClient( - url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, - configuration: StorageClientConfiguration( - headers: [ - "Authorization": "Bearer \(DotEnv.SUPABASE_SECRET_KEY)" - ], - logger: nil - ) - ) - - var bucketName = "" - var file = Data() - var uploadPath = "" - - override func setUp() async throws { - try await super.setUp() - - try XCTSkipUnless( - ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil, - "INTEGRATION_TESTS not defined." - ) - - bucketName = try await newBucket() - file = try Data(contentsOf: uploadFileURL("sadcat.jpg")) - uploadPath = "testpath/file-\(UUID().uuidString).jpg" - } - - override func tearDown() async throws { - try? await storage.emptyBucket(bucketName) - try? await storage.deleteBucket(bucketName) - - try await super.tearDown() - } - - func testGetPublicURL() throws { - let publicURL = try storage.from(bucketName).getPublicURL(path: uploadPath) - XCTAssertEqual( - publicURL.absoluteString, - "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)" - ) - } - - func testGetPublicURLWithDownloadQueryString() throws { - let publicURL = try storage.from(bucketName).getPublicURL( - path: uploadPath, download: .withOriginalName) - XCTAssertEqual( - publicURL.absoluteString, - "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)?download=" - ) - } - - func testGetPublicURLWithCustomDownload() throws { - let publicURL = try storage.from(bucketName).getPublicURL( - path: uploadPath, download: .named("test.jpg")) - XCTAssertEqual( - publicURL.absoluteString, - "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)?download=test.jpg" - ) - } - - func testSignURL() async throws { - _ = try await storage.from(bucketName).upload(uploadPath, data: file) - - let url = try await storage.from(bucketName).createSignedURL( - path: uploadPath, expiresIn: .seconds(2000)) - XCTAssertTrue( - url.absoluteString.contains( - "\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") - ) - } - - func testSignURL_withDownloadQueryString() async throws { - _ = try await storage.from(bucketName).upload(uploadPath, data: file) - - let url = try await storage.from(bucketName).createSignedURL( - path: uploadPath, expiresIn: .seconds(2000), download: .withOriginalName) - XCTAssertTrue( - url.absoluteString.contains( - "\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") - ) - XCTAssertTrue(url.absoluteString.contains("&download=")) - } - - func testSignURL_withCustomFilenameForDownload() async throws { - _ = try await storage.from(bucketName).upload(uploadPath, data: file) - - let url = try await storage.from(bucketName).createSignedURL( - path: uploadPath, expiresIn: .seconds(2000), download: .named("test.jpg")) - XCTAssertTrue( - url.absoluteString.contains( - "\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") - ) - XCTAssertTrue(url.absoluteString.contains("&download=test.jpg")) - } - - func testUploadAndUpdateFile() async throws { - let file2 = try Data(contentsOf: uploadFileURL("file-2.txt")) - - try await storage.from(bucketName).upload(uploadPath, data: file) - - let res = try await storage.from(bucketName).update(uploadPath, data: file2) - XCTAssertEqual(res.path, uploadPath) - } - - func testUploadFileWithinFileSizeLimit() async throws { - bucketName = try await newBucket( - prefix: "with-limit", - options: BucketOptions(isPublic: true, fileSizeLimit: .megabytes(1)) - ) - - try await storage.from(bucketName).upload(uploadPath, data: file) - } - - func testUploadFileThatExceedFileSizeLimit() async throws { - bucketName = try await newBucket( - prefix: "with-limit", - options: BucketOptions(isPublic: true, fileSizeLimit: .kilobytes(1)) - ) - - do { - try await storage.from(bucketName).upload(uploadPath, data: file) - XCTFail("Unexpected success") - } catch let error as StorageError { - XCTAssertEqual(error.statusCode, 413) - XCTAssertEqual(error.message, "The object exceeded the maximum allowed size") - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testUploadFileWithValidMimeType() async throws { - bucketName = try await newBucket( - prefix: "with-mimetype", - options: BucketOptions(isPublic: true, allowedMimeTypes: ["image/jpeg"]) - ) - - try await storage.from(bucketName).upload( - uploadPath, - data: file, - options: FileOptions( - contentType: "image/jpeg" - ) - ) - } - - func testUploadFileWithInvalidMimeType() async throws { - bucketName = try await newBucket( - prefix: "with-mimetype", - options: BucketOptions(isPublic: true, allowedMimeTypes: ["image/png"]) - ) - - do { - try await storage.from(bucketName).upload( - uploadPath, - data: file, - options: FileOptions( - contentType: "image/jpeg" - ) - ) - XCTFail("Unexpected success") - } catch let error as StorageError { - XCTAssertEqual(error.statusCode, 415) - XCTAssertEqual(error.message, "mime type image/jpeg is not supported") - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testSignedURLForUpload() async throws { - let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) - XCTAssertEqual(res.path, uploadPath) - XCTAssertTrue( - res.signedURL.absoluteString.contains( - "\(DotEnv.SUPABASE_URL)/storage/v1/object/upload/sign/\(bucketName)/\(uploadPath)" - ) - ) - } - - func testCanUploadWithSignedURLForUpload() async throws { - let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) - - let uploadRes = try await storage.from(bucketName).uploadToSignedURL( - res.path, token: res.token, data: file) - XCTAssertEqual(uploadRes.path, uploadPath) - } - - func testCanUploadOverwritingFilesWithSignedURL() async throws { - try await storage.from(bucketName).upload(uploadPath, data: file) - - let res = try await storage.from(bucketName).createSignedUploadURL( - path: uploadPath, options: CreateSignedUploadURLOptions(upsert: true)) - let uploadRes = try await storage.from(bucketName).uploadToSignedURL( - res.path, token: res.token, data: file) - XCTAssertEqual(uploadRes.path, uploadPath) - } - - func testCannotUploadToSignedURLTwice() async throws { - let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) - - try await storage.from(bucketName).uploadToSignedURL(res.path, token: res.token, data: file) - - do { - try await storage.from(bucketName).uploadToSignedURL(res.path, token: res.token, data: file) - XCTFail("Unexpected success") - } catch let error as StorageError { - XCTAssertEqual(error.statusCode, 409) - XCTAssertEqual(error.message, "The resource already exists") - } catch { - XCTFail("Unexpected error type: \(error)") - } - } - - func testListObjects() async throws { - try await storage.from(bucketName).upload(uploadPath, data: file) - let res = try await storage.from(bucketName).list(path: "testpath") - - XCTAssertEqual(res.count, 1) - XCTAssertEqual(res[0].name, uploadPath.replacingOccurrences(of: "testpath/", with: "")) - } - - func testMoveObjectToDifferentPath() async throws { - let newPath = "testpath/file-moved-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(uploadPath, data: file) - - try await storage.from(bucketName).move(from: uploadPath, to: newPath) - } - - func testMoveObjectsAcrossBucketsInDifferentPath() async throws { - let newBucketName = "bucket-move" - try await findOrCreateBucket(name: newBucketName) - - let newPath = "testpath/file-to-move-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(uploadPath, data: file) - - try await storage.from(bucketName).move( - from: uploadPath, - to: newPath, - options: DestinationOptions(destinationBucket: newBucketName) - ) - - _ = try await storage.from(newBucketName).download(path: newPath) - } - - func testCopyObjectToDifferentPath() async throws { - let newPath = "testpath/file-moved-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(uploadPath, data: file) - - try await storage.from(bucketName).copy(from: uploadPath, to: newPath) - } - - func testCopyObjectsAcrossBucketsInDifferentPath() async throws { - let newBucketName = "bucket-copy" - try await findOrCreateBucket(name: newBucketName) - - let newPath = "testpath/file-to-copy-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(uploadPath, data: file) - - try await storage.from(bucketName).copy( - from: uploadPath, - to: newPath, - options: DestinationOptions(destinationBucket: newBucketName) - ) - - _ = try await storage.from(newBucketName).download(path: newPath) - } - - func testDownloadsAnObject() async throws { - try await storage.from(bucketName).upload(uploadPath, data: file) - - let res = try await storage.from(bucketName).download(path: uploadPath) - XCTAssertGreaterThan(res.count, 0) - } - - func testRemovesAnObject() async throws { - try await storage.from(bucketName).upload(uploadPath, data: file) - - let res = try await storage.from(bucketName).remove(paths: [uploadPath]) - XCTAssertEqual(res.count, 1) - XCTAssertEqual(res[0].bucketId, bucketName) - XCTAssertEqual(res[0].name, uploadPath) - } - - func testGetPublishURLWithTransformationOptions() throws { - let res = try storage.from(bucketName).getPublicURL( - path: uploadPath, - options: TransformOptions( - width: 700, - height: 300, - quality: 70 - ) - ) - - XCTAssertEqual( - res.absoluteString, - "\(DotEnv.SUPABASE_URL)/storage/v1/render/image/public/\(bucketName)/\(uploadPath)?width=700&height=300&quality=70" - ) - } - - func testCreateAndLoadEmptyFolder() async throws { - let path = "empty-folder/.placeholder" - try await storage.from(bucketName).upload(path, data: Data()) - - let files = try await storage.from(bucketName).list() - assertInlineSnapshot(of: files, as: .json) { - """ - [ - { - "name" : "empty-folder" - } - ] - """ - } - } - - func testInfo() async throws { - try await storage.from(bucketName).upload( - uploadPath, - data: file, - options: FileOptions( - metadata: ["value": 42] - ) - ) - - let info = try await storage.from(bucketName).info(path: uploadPath) - XCTAssertEqual(info.name, uploadPath) - XCTAssertEqual(info.metadata, ["value": 42]) - } - - func testExists() async throws { - try await storage.from(bucketName).upload(uploadPath, data: file) - - var exists = try await storage.from(bucketName).exists(path: uploadPath) - XCTAssertTrue(exists) - - exists = try await storage.from(bucketName).exists(path: "invalid.jpg") - XCTAssertFalse(exists) - } - - func testUploadWithCacheControl() async throws { - try await storage.from(bucketName).upload( - uploadPath, - data: file, - options: FileOptions( - cacheControl: "14400" - ) - ) - - let publicURL = try storage.from(bucketName).getPublicURL(path: uploadPath) - - let (_, response) = try await URLSession.shared.data(from: publicURL) - let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) - let cacheControl = try XCTUnwrap(httpResponse.value(forHTTPHeaderField: "cache-control")) - - XCTAssertEqual(cacheControl, "max-age=14400") - } - - func testUploadWithFileURL() async throws { - try await storage.from(bucketName) - .upload(uploadPath, fileURL: uploadFileURL("sadcat.jpg")) - - let uploadedFile = try await storage.from(bucketName).download(path: uploadPath) - - XCTAssertEqual(uploadedFile, file) - } - - private func newBucket( - prefix: String = "", - options: BucketOptions = BucketOptions(isPublic: true) - ) async throws -> String { - let bucketName = "\(!prefix.isEmpty ? prefix + "-" : "")bucket-\(UUID().uuidString)" - return try await findOrCreateBucket(name: bucketName, options: options) - } - - @discardableResult - private func findOrCreateBucket( - name: String, - options: BucketOptions = BucketOptions(isPublic: true) - ) async throws -> String { - do { - _ = try await storage.getBucket(name) - } catch { - try await storage.createBucket(name, options: options) - } - - return name - } - - private func uploadFileURL(_ fileName: String) -> URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .appendingPathComponent("Fixtures/Upload") - .appendingPathComponent(fileName) - } -} diff --git a/Tests/StorageTests/MultipartUploadEngineTests.swift b/Tests/StorageTests/MultipartUploadEngineTests.swift new file mode 100644 index 00000000..12a04173 --- /dev/null +++ b/Tests/StorageTests/MultipartUploadEngineTests.swift @@ -0,0 +1,157 @@ +// +// MultipartUploadEngineTests.swift +// StorageTests +// + +import ConcurrencyExtras +import Foundation +import Mocker +import Testing + +@testable import Storage + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +@Suite struct MultipartUploadEngineStateTests { + let dummyResponse = FileUploadResponse( + id: UUID(), path: "f.txt", fullPath: "bucket/f.txt") + let dummyError = StorageError(message: "oops", errorCode: .unknown) + + @Test func nonTerminalStatesReturnFalse() { + #expect(!MultipartUploadEngine.State.idle.isTerminal) + #expect(!MultipartUploadEngine.State.uploading.isTerminal) + } + + @Test func terminalStatesReturnTrue() { + #expect(MultipartUploadEngine.State.completed(dummyResponse).isTerminal) + #expect(MultipartUploadEngine.State.failed(dummyError).isTerminal) + #expect(MultipartUploadEngine.State.cancelled.isTerminal) + } +} + +@Suite(.serialized) struct MultipartUploadEngineTests { + + let baseURL = URL(string: "http://localhost:54321/storage/v1")! + + var client: StorageClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + return StorageClient( + url: baseURL, + configuration: StorageClientConfiguration( + headers: ["Authorization": "Bearer test-token"], + session: session + ) + ) + } + + @Test func uploadDataCompletesAndReturnsResponse() async throws { + let objectURL = baseURL.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + Mock( + url: objectURL, + contentType: .json, + statusCode: 200, + data: [.post: Data(responseJSON.utf8)] + ).register() + + let task = MultipartUploadEngine.makeTask( + bucketId: "bucket", + path: "file.txt", + source: .data(Data("hello world".utf8)), + options: FileOptions(), + client: client + ) + + let response = try await task.value + #expect(response.path == "file.txt") + #expect(response.fullPath == "bucket/file.txt") + #expect(response.id == UUID(uuidString: "EAA8BDB5-2E00-4767-B5A9-D2502EFE2196")) + } + + @Test func uploadSetsUpsertHeader() async throws { + let objectURL = baseURL.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + + let capturedRequest = LockIsolated(nil) + var mock = Mock( + url: objectURL, + contentType: .json, + statusCode: 200, + data: [.post: Data(responseJSON.utf8)] + ) + mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) + mock.register() + + let options = FileOptions(upsert: true) + let task = MultipartUploadEngine.makeTask( + bucketId: "bucket", + path: "file.txt", + source: .data(Data("hello".utf8)), + options: options, + client: client + ) + _ = try await task.value + + let req = try #require(capturedRequest.value) + #expect(req.value(forHTTPHeaderField: "x-upsert") == "true") + } + + @Test func uploadSetsMultipartContentType() async throws { + let objectURL = baseURL.appendingPathComponent("object/bucket/photo.jpg") + let responseJSON = """ + {"Key":"bucket/photo.jpg","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + + let capturedRequest = LockIsolated(nil) + var mock = Mock( + url: objectURL, + contentType: .json, + statusCode: 200, + data: [.post: Data(responseJSON.utf8)] + ) + mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) + mock.register() + + let task = MultipartUploadEngine.makeTask( + bucketId: "bucket", + path: "photo.jpg", + source: .data(Data("imagedata".utf8)), + options: FileOptions(), + client: client + ) + _ = try await task.value + + let req = try #require(capturedRequest.value) + let contentType = try #require(req.value(forHTTPHeaderField: "Content-Type")) + #expect(contentType.hasPrefix("multipart/form-data; boundary=")) + } + + @Test func cancelFinishesWithCancelledError() async { + // No mock registered — cancel immediately before network call completes. + let task = MultipartUploadEngine.makeTask( + bucketId: "bucket", + path: "file.txt", + source: .data(Data("hello".utf8)), + options: FileOptions(), + client: client + ) + await task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation error") + } catch let error as StorageError { + #expect(error.errorCode == .cancelled) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index 188be2f9..12382432 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -1,3 +1,4 @@ +import ConcurrencyExtras import Foundation import Helpers import InlineSnapshotTesting @@ -510,197 +511,74 @@ struct StorageFileAPITests { } @Test func updateFromData() async throws { + let objectURL = url.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), + url: objectURL, + contentType: .json, statusCode: 200, - data: [ - .put: Data( - """ - { - "Id": "eaa8bdb5-2e00-4767-b5a9-d2502efe2196", - "Key": "bucket/file.txt" - } - """.utf8 - ) - ] - ) - .snapshotRequest { - #""" - curl \ - --request PUT \ - --header "Accept: application/json" \ - --header "Cache-Control: max-age=3600" \ - --header "Content-Length: 390" \ - --header "Content-Type: multipart/form-data; boundary=alamofire.boundary.e56f43407f772505" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - --data "--alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"cacheControl\"\#r - \#r - 3600\#r - --alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"metadata\"\#r - \#r - {\"mode\":\"test\"}\#r - --alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"\"; filename=\"file.txt\"\#r - Content-Type: text/plain\#r - \#r - hello world\#r - --alamofire.boundary.e56f43407f772505--\#r - " \ - "http://localhost:54321/storage/v1/object/bucket/file.txt" - """# - } - .register() + data: [.post: Data(responseJSON.utf8)] + ).register() let response = try await storage.from("bucket") .update( "file.txt", - data: Data("hello world".utf8), - options: FileOptions( - metadata: [ - "mode": "test" - ] - ) - ) + data: Data("hello world".utf8) + ).value - #expect(response.id == UUID(uuidString: "eaa8bdb5-2e00-4767-b5a9-d2502efe2196")) #expect(response.path == "file.txt") #expect(response.fullPath == "bucket/file.txt") } - @Test func updateFromURL() async throws { - Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), - statusCode: 200, - data: [ - .put: Data( - """ - { - "Id": "eaa8bdb5-2e00-4767-b5a9-d2502efe2196", - "Key": "bucket/file.txt" - } - """.utf8 - ) - ] - ) - .snapshotRequest { - #""" - curl \ - --request PUT \ - --header "Accept: application/json" \ - --header "Cache-Control: max-age=3600" \ - --header "Content-Length: 392" \ - --header "Content-Type: multipart/form-data; boundary=alamofire.boundary.e56f43407f772505" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - --data "--alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"cacheControl\"\#r - \#r - 3600\#r - --alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"metadata\"\#r - \#r - {\"mode\":\"test\"}\#r - --alamofire.boundary.e56f43407f772505\#r - Content-Disposition: form-data; name=\"\"; filename=\"file.txt\"\#r - Content-Type: text/plain\#r - \#r - hello world! - \#r - --alamofire.boundary.e56f43407f772505--\#r - " \ - "http://localhost:54321/storage/v1/object/bucket/file.txt" - """# - } - .register() - - let response = try await storage.from("bucket") - .update( - "file.txt", - fileURL: Bundle.module.url(forResource: "file", withExtension: "txt")!, - options: FileOptions( - metadata: [ - "mode": "test" - ] - ) - ) - - #expect(response.id == UUID(uuidString: "eaa8bdb5-2e00-4767-b5a9-d2502efe2196")) - #expect(response.path == "file.txt") - #expect(response.fullPath == "bucket/file.txt") - } + @Test func updateSetsUpsertTrue() async throws { + let objectURL = url.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + let capturedRequest = LockIsolated(nil) - @Test func download() async throws { - Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), + var mock = Mock( + url: objectURL, + contentType: .json, statusCode: 200, - data: [ - .get: Data("hello world".utf8) - ] + data: [.post: Data(responseJSON.utf8)] ) - .snapshotRequest { - #""" - curl \ - --header "Accept: application/json" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:54321/storage/v1/object/bucket/file.txt" - """# - } - .register() + mock.onRequestHandler = OnRequestHandler(requestCallback: { request in + capturedRequest.setValue(request) + }) + mock.register() - let data = try await storage.from("bucket") - .download(path: "file.txt") + _ = try await storage.from("bucket") + .update("file.txt", data: Data("hello".utf8)).value - #expect(data == Data("hello world".utf8)) + let request = try #require(capturedRequest.value) + #expect(request.value(forHTTPHeaderField: "x-upsert") == "true") } - @Test func downloadWithAdditionalQuery() async throws { + @Test func updateFromURL() async throws { + let objectURL = url.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), - ignoreQuery: true, + url: objectURL, + contentType: .json, statusCode: 200, - data: [ - .get: Data("hello world".utf8) - ] - ) - .snapshotRequest { - #""" - curl \ - --header "Accept: application/json" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:54321/storage/v1/object/bucket/file.txt?version=1" - """# - } - .register() + data: [.post: Data(responseJSON.utf8)] + ).register() - let data = try await storage.from("bucket") - .download( - path: "file.txt", - query: [URLQueryItem(name: "version", value: "1")] - ) + let response = try await storage.from("bucket") + .update( + "file.txt", + fileURL: Bundle.module.url(forResource: "file", withExtension: "txt")! + ).value - #expect(data == Data("hello world".utf8)) + #expect(response.path == "file.txt") + #expect(response.fullPath == "bucket/file.txt") } - @Test func download_withEmptyTransformOptions() async throws { - Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), - statusCode: 200, - data: [ - .get: Data("hello world".utf8) - ] - ) - .register() - - let data = try await storage.from("bucket") - .download(path: "file.txt", options: TransformOptions()) - - #expect(data == Data("hello world".utf8)) - } @Test func getPublicURL_withEmptyTransformOptions() throws { let publicURL = try storage.from("bucket") @@ -726,44 +604,6 @@ struct StorageFileAPITests { ) } - @Test func download_withOptions() async throws { - let imageData = try Data( - contentsOf: Bundle.module.url( - forResource: "sadcat", - withExtension: "jpg" - )! - ) - - Mock( - url: url.appendingPathComponent( - "render/image/authenticated/bucket/sadcat.txt" - ), - ignoreQuery: true, - statusCode: 200, - data: [ - .get: imageData - ] - ) - .snapshotRequest { - #""" - curl \ - --header "Accept: application/json" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:54321/storage/v1/render/image/authenticated/bucket/sadcat.txt?format=origin" - """# - } - .register() - - let data = try await storage.from("bucket") - .download( - path: "sadcat.txt", - options: TransformOptions(format: .origin) - ) - - #expect(data == imageData) - } - @Test func info() async throws { Mock( url: url.appendingPathComponent("object/info/bucket/file.txt"), @@ -998,7 +838,7 @@ struct StorageFileAPITests { "file.txt", token: "abc.def.ghi", data: Data("hello world".utf8) - ) + ).value #expect(response.path == "file.txt") #expect(response.fullPath == "bucket/file.txt") @@ -1052,7 +892,7 @@ struct StorageFileAPITests { "file.txt", token: "abc.def.ghi", fileURL: Bundle.module.url(forResource: "file", withExtension: "txt")! - ) + ).value #expect(response.path == "file.txt") #expect(response.fullPath == "bucket/file.txt") @@ -1112,7 +952,7 @@ struct StorageFileAPITests { "file.txt", token: "abc.def.ghi", fileURL: fileURL - ) + ).value #expect(response.path == "file.txt") #expect(response.fullPath == "bucket/file.txt") @@ -1191,57 +1031,41 @@ struct StorageFileAPITests { ) } - @Test func uploadWithProgressClosure() async throws { + @Test func uploadSmallDataUsesMultipart() async throws { + // 1 byte — well below 6 MB threshold → should POST to /object/... + let objectURL = url.appendingPathComponent("object/bucket/small.txt") + let responseJSON = """ + {"Key":"bucket/small.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), - statusCode: 200, - data: [ - .post: Data( - """ - { - "Id": "eaa8bdb5-2e00-4767-b5a9-d2502efe2196", - "Key": "bucket/file.txt" - } - """.utf8 - ) - ] - ) - .register() + url: objectURL, contentType: .json, statusCode: 200, + data: [.post: Data(responseJSON.utf8)] + ).register() - let response = try await storage.from("bucket").upload( - "file.txt", - data: Data("hello world".utf8), - progress: { _ in } - ) + let response = try await storage.from("bucket") + .upload("small.txt", data: Data("x".utf8)).value - #expect(response.id == UUID(uuidString: "eaa8bdb5-2e00-4767-b5a9-d2502efe2196")) - #expect(response.path == "file.txt") - #expect(response.fullPath == "bucket/file.txt") + #expect(response.path == "small.txt") + #expect(response.fullPath == "bucket/small.txt") } - @Test func download_cacheNonce() async throws { - Mock( - url: url.appendingPathComponent("object/bucket/file.txt"), - ignoreQuery: true, - statusCode: 200, - data: [ - .get: Data("hello world".utf8) - ] + @Test func updateSmallDataUsesMultipart() async throws { + let objectURL = url.appendingPathComponent("object/bucket/small.txt") + let responseJSON = """ + {"Key":"bucket/small.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + let capturedRequest = LockIsolated(nil) + var mock = Mock( + url: objectURL, contentType: .json, statusCode: 200, + data: [.post: Data(responseJSON.utf8)] ) - .snapshotRequest { - #""" - curl \ - --header "Accept: application/json" \ - --header "X-Client-Info: storage-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:54321/storage/v1/object/bucket/file.txt?cacheNonce=abc123" - """# - } - .register() + mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) + mock.register() - let data = try await storage.from("bucket") - .download(path: "file.txt", cacheNonce: "abc123") + _ = try await storage.from("bucket") + .update("small.txt", data: Data("x".utf8)).value - #expect(data == Data("hello world".utf8)) + let req = try #require(capturedRequest.value) + #expect(req.value(forHTTPHeaderField: "x-upsert") == "true") } } diff --git a/Tests/StorageTests/StorageTransferTaskTests.swift b/Tests/StorageTests/StorageTransferTaskTests.swift new file mode 100644 index 00000000..d7a45e37 --- /dev/null +++ b/Tests/StorageTests/StorageTransferTaskTests.swift @@ -0,0 +1,182 @@ +// +// StorageTransferTaskTests.swift +// Storage +// +// Created by Guilherme Souza on 04/05/26. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import Storage + +@Suite struct StorageTransferTaskTests { + + @Test func transferProgressFractionCompleted() { + let p = TransferProgress(bytesTransferred: 25, totalBytes: 100) + #expect(p.fractionCompleted == 0.25) + } + + @Test func transferProgressFractionCompletedWhenTotalIsZero() { + let p = TransferProgress(bytesTransferred: 0, totalBytes: 0) + #expect(p.fractionCompleted == 0) + } + + @Test func eventsStreamDeliversProgressAndCompletion() async throws { + let task = makeTask(success: "hello") + var events: [TransferEvent] = [] + for await event in task.events { + events.append(event) + } + #expect(events.count == 2) + if case .progress(let p) = events[0] { + #expect(p.bytesTransferred == 10) + } else { + Issue.record("Expected .progress as first event") + } + if case .completed(let v) = events[1] { + #expect(v == "hello") + } else { + Issue.record("Expected .completed as second event") + } + } + + @Test func resultReturnsSuccessValue() async throws { + let task = makeTask(success: "world") + let result = try await task.value + #expect(result == "world") + } + + @Test func resultThrowsOnFailure() async { + let task: StorageTransferTask = makeFailingTask(StorageError.cancelled) + do { + _ = try await task.value + Issue.record("Expected throw") + } catch let error as StorageError { + #expect(error.errorCode == .cancelled) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func mapResultTransformsSuccess() async throws { + let task = makeTask(success: 42) + let mapped = task.mapResult { "\($0)" } + let result = try await mapped.value + #expect(result == "42") + } + + @Test func mapResultForwardsProgress() async throws { + let task = makeTask(success: 42) + let mapped = task.mapResult { $0 * 2 } + var progressSeen = false + for await event in mapped.events { + if case .progress = event { progressSeen = true } + } + #expect(progressSeen) + } + + @Test func mapResultPropagatesFailure() async { + let task: StorageTransferTask = makeFailingTask(StorageError.cancelled) + let mapped = task.mapResult { "\($0)" } + do { + _ = try await mapped.value + Issue.record("Expected throw") + } catch let error as StorageError { + #expect(error.errorCode == .cancelled) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test func cancelInvokesCancelClosure() async { + let cancelCalled = LockIsolated(false) + let (eventStream, _) = AsyncStream>.makeStream() + let (resultStream, _) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + let task = StorageTransferTask( + events: eventStream, + resultTask: resultTask, + pause: {}, + resume: {}, + cancel: { cancelCalled.setValue(true) } + ) + await task.cancel() + #expect(cancelCalled.value) + } + + @Test func mapResultTransformThrowsPropagatesAsFailure() async { + let task = makeTask(success: 42) + let mapped = task.mapResult { (_: Int) throws -> String in + throw NSError(domain: "test", code: 1) + } + var gotFailure = false + for await event in mapped.events { + if case .failed(let error) = event { + gotFailure = true + #expect(error.errorCode == .fileSystemError) + } + } + #expect(gotFailure) + } +} + +// MARK: - Helpers + +private func makeTask(success value: Success) -> StorageTransferTask { + let (eventStream, eventsContinuation) = AsyncStream>.makeStream() + let (resultStream, resultContinuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + let task = StorageTransferTask( + events: eventStream, + resultTask: resultTask, + pause: {}, + resume: {}, + cancel: {} + ) + + eventsContinuation.yield(.progress(TransferProgress(bytesTransferred: 10, totalBytes: 100))) + eventsContinuation.yield(.completed(value)) + eventsContinuation.finish() + resultContinuation.yield(.success(value)) + + return task +} + +private func makeFailingTask(_ error: StorageError) -> StorageTransferTask< + Success +> { + let (eventStream, eventsContinuation) = AsyncStream>.makeStream() + let (resultStream, resultContinuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + let task = StorageTransferTask( + events: eventStream, + resultTask: resultTask, + pause: {}, + resume: {}, + cancel: {} + ) + + eventsContinuation.yield(.failed(error)) + eventsContinuation.finish() + resultContinuation.yield(.failure(error)) + + return task +} diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index 44ef802d..b83d813a 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -114,30 +114,17 @@ struct SupabaseStorageTests { #if !os(Linux) && !os(Android) @Test func uploadData() async throws { - testingBoundary.setValue("alamofire.boundary.c21f947c1c7b0c57") + let capturedRequest = LockIsolated(nil) StorageURLProtocolMock.requestHandler.setValue { request in - assertInlineSnapshot(of: request, as: .curl) { - #""" - curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Apikey: test.api.key" \ - --header "Cache-Control: max-age=14400" \ - --header "Content-Length: 390" \ - --header "Content-Type: multipart/form-data; boundary=alamofire.boundary.c21f947c1c7b0c57" \ - --header "X-Client-Info: storage-swift/x.y.z" \ - --header "x-upsert: false" \ - "http://localhost:54321/storage/v1/object/tests/file1.txt" - """# - } - return ( + capturedRequest.setValue(request) + let data = Data( """ - { - "Id": "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", - "Key": "tests/file1.txt" - } - """.data(using: .utf8)!, + {"Key":"tests/file1.txt","Id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"} + """.utf8 + ) + return ( + data, HTTPURLResponse( url: Self.supabaseURL, statusCode: 200, @@ -156,35 +143,25 @@ struct SupabaseStorageTests { options: FileOptions( cacheControl: "14400", metadata: ["key": "value"] - ) - ) + ), + ).value + + let req = try #require(capturedRequest.value) + #expect(req.httpMethod == "POST") } @Test func uploadFileURL() async throws { - testingBoundary.setValue("alamofire.boundary.c21f947c1c7b0c57") + let capturedRequest = LockIsolated(nil) StorageURLProtocolMock.requestHandler.setValue { request in - assertInlineSnapshot(of: request, as: .curl) { - #""" - curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Apikey: test.api.key" \ - --header "Cache-Control: max-age=3600" \ - --header "Content-Length: 29907" \ - --header "Content-Type: multipart/form-data; boundary=alamofire.boundary.c21f947c1c7b0c57" \ - --header "X-Client-Info: storage-swift/x.y.z" \ - --header "x-upsert: false" \ - "http://localhost:54321/storage/v1/object/tests/sadcat.jpg" - """# - } - return ( + capturedRequest.setValue(request) + let data = Data( """ - { - "Id": "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", - "Key": "tests/file1.txt" - } - """.data(using: .utf8)!, + {"Key":"tests/sadcat.jpg","Id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F"} + """.utf8 + ) + return ( + data, HTTPURLResponse( url: Self.supabaseURL, statusCode: 200, @@ -202,8 +179,11 @@ struct SupabaseStorageTests { fileURL: uploadFileURL("sadcat.jpg"), options: FileOptions( metadata: ["key": "value"] - ) - ) + ), + ).value + + let req = try #require(capturedRequest.value) + #expect(req.httpMethod == "POST") } #endif diff --git a/Tests/StorageTests/UploadProgressTests.swift b/Tests/StorageTests/UploadProgressTests.swift index 0e6349b6..f16cdd10 100644 --- a/Tests/StorageTests/UploadProgressTests.swift +++ b/Tests/StorageTests/UploadProgressTests.swift @@ -3,25 +3,25 @@ import Testing @testable import Storage @Suite -struct UploadProgressTests { +struct TransferProgressTests { @Test func fractionCompleted_midUpload() { - let progress = UploadProgress(totalBytesSent: 500, totalBytesExpectedToSend: 1000) + let progress = TransferProgress(bytesTransferred: 500, totalBytes: 1000) #expect(abs(progress.fractionCompleted - 0.5) < 0.001) } @Test func fractionCompleted_complete() { - let progress = UploadProgress(totalBytesSent: 1000, totalBytesExpectedToSend: 1000) + let progress = TransferProgress(bytesTransferred: 1000, totalBytes: 1000) #expect(abs(progress.fractionCompleted - 1.0) < 0.001) } @Test func fractionCompleted_zeroTotal() { - let progress = UploadProgress(totalBytesSent: 0, totalBytesExpectedToSend: 0) + let progress = TransferProgress(bytesTransferred: 0, totalBytes: 0) #expect(progress.fractionCompleted == 0.0) } @Test func fractionCompleted_start() { - let progress = UploadProgress(totalBytesSent: 0, totalBytesExpectedToSend: 2048) + let progress = TransferProgress(bytesTransferred: 0, totalBytes: 2048) #expect(progress.fractionCompleted == 0.0) } } From 4c5044d090322515d409cd2bf6296d7bb51f631f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 7 May 2026 06:53:57 -0300 Subject: [PATCH 2/4] ci: remove branch filter so CI runs on stacked PRs --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b45d78ef..d161d6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,14 +2,7 @@ name: CI on: push: - branches: - - main - - v3 - - release/* pull_request: - branches: - - "*" - - release/* workflow_dispatch: concurrency: From 0eefab23c4fd61b8637b25030bb6052646430cdc Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 7 May 2026 06:58:05 -0300 Subject: [PATCH 3/4] style: apply swift-format to changed files --- Sources/Storage/StorageClient.swift | 2 -- Sources/Storage/StorageFileAPI.swift | 1 - Sources/Storage/Types.swift | 1 - Tests/StorageTests/StorageFileAPITests.swift | 1 - 4 files changed, 5 deletions(-) diff --git a/Sources/Storage/StorageClient.swift b/Sources/Storage/StorageClient.swift index 1689e59c..fd4c798f 100644 --- a/Sources/Storage/StorageClient.swift +++ b/Sources/Storage/StorageClient.swift @@ -114,7 +114,6 @@ public final class StorageClient: Sendable { package let http: _HTTPClient private let usesTokenProvider: Bool - let encoder: JSONEncoder = { let encoder = JSONEncoder.supabase() encoder.keyEncodingStrategy = .convertToSnakeCase @@ -338,7 +337,6 @@ public final class StorageClient: Sendable { configuration.logger?.error("Response: Failure \(error)") } - /// Returns a ``StorageFileAPI`` scoped to the given bucket. /// /// All file operations — upload, download, list, delete, signed URLs — are performed through diff --git a/Sources/Storage/StorageFileAPI.swift b/Sources/Storage/StorageFileAPI.swift index c7c62b95..b0de5d12 100644 --- a/Sources/Storage/StorageFileAPI.swift +++ b/Sources/Storage/StorageFileAPI.swift @@ -101,7 +101,6 @@ public struct StorageFileAPI: Sendable { let Key: String } - /// Uploads a `Data` value to an existing bucket. /// /// - Parameters: diff --git a/Sources/Storage/Types.swift b/Sources/Storage/Types.swift index fd20797b..84ac09c2 100644 --- a/Sources/Storage/Types.swift +++ b/Sources/Storage/Types.swift @@ -1,7 +1,6 @@ import Foundation import Helpers - /// Parameters used to filter and paginate results from ``StorageFileAPI/list(path:options:)``. /// /// All fields are optional; omitted fields fall back to server-side defaults (100 items per page, diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index 12382432..0b1bd9ab 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -579,7 +579,6 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/file.txt") } - @Test func getPublicURL_withEmptyTransformOptions() throws { let publicURL = try storage.from("bucket") .getPublicURL(path: "image.png", options: TransformOptions()) From 59cd95abaddfa2ec23e0abf0422d95ee188f33b1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 8 May 2026 06:06:11 -0300 Subject: [PATCH 4/4] fix(storage): update uses PUT with no x-upsert header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update() was delegating to upload() with upsert: true, which sent a POST + x-upsert header. The correct behaviour (matching supabase-js) is a PUT request with no x-upsert header. TUS does not have an update concept, so the method: parameter is removed from both update overloads. - MultipartUploadEngine.makeTask now accepts httpMethod (default .post) - update(data:) and update(fileURL:) call multipart with httpMethod: .put - Tests updated: POST mocks → PUT mocks, upsert assertions removed, new assertion verifies PUT method and absence of x-upsert header --- Sources/Storage/MultipartUploadEngine.swift | 7 +- Sources/Storage/StorageFileAPI.swift | 190 +++++++++++++++++-- Tests/StorageTests/StorageFileAPITests.swift | 124 +++++++++++- 3 files changed, 296 insertions(+), 25 deletions(-) diff --git a/Sources/Storage/MultipartUploadEngine.swift b/Sources/Storage/MultipartUploadEngine.swift index 59004a84..b93f3484 100644 --- a/Sources/Storage/MultipartUploadEngine.swift +++ b/Sources/Storage/MultipartUploadEngine.swift @@ -36,6 +36,7 @@ actor MultipartUploadEngine { private let path: String private let source: UploadSource private let options: FileOptions + private let httpMethod: HTTPMethod private let client: StorageClient private let eventsContinuation: AsyncStream>.Continuation private let resultContinuation: AsyncStream>.Continuation @@ -48,6 +49,7 @@ actor MultipartUploadEngine { path: String, source: UploadSource, options: FileOptions, + httpMethod: HTTPMethod = .post, client: StorageClient, eventsContinuation: AsyncStream>.Continuation, resultContinuation: AsyncStream>.Continuation @@ -56,6 +58,7 @@ actor MultipartUploadEngine { self.path = path self.source = source self.options = options + self.httpMethod = httpMethod self.client = client self.eventsContinuation = eventsContinuation self.resultContinuation = resultContinuation @@ -119,7 +122,7 @@ actor MultipartUploadEngine { } let request = try await client.http.createRequest( - .post, + httpMethod, url: url, headers: client.mergedHeaders(headers) ) @@ -218,6 +221,7 @@ extension MultipartUploadEngine { path: String, source: UploadSource, options: FileOptions, + httpMethod: HTTPMethod = .post, client: StorageClient ) -> StorageUploadTask { let (eventStream, eventsContinuation) = @@ -231,6 +235,7 @@ extension MultipartUploadEngine { path: path, source: source, options: options, + httpMethod: httpMethod, client: client, eventsContinuation: eventsContinuation, resultContinuation: resultContinuation diff --git a/Sources/Storage/StorageFileAPI.swift b/Sources/Storage/StorageFileAPI.swift index b0de5d12..e419077a 100644 --- a/Sources/Storage/StorageFileAPI.swift +++ b/Sources/Storage/StorageFileAPI.swift @@ -108,7 +108,11 @@ public struct StorageFileAPI: Sendable { /// The bucket must already exist. /// - data: The raw file bytes to store. /// - options: Upload options such as content type, cache duration, and upsert behaviour. - /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. + /// - method: The upload protocol to use. Defaults to ``UploadMethod/auto``, which picks + /// multipart for files ≤ 6 MB and TUS resumable for larger files. + /// Pass ``UploadMethod/multipart`` or ``UploadMethod/resumable`` to override. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress, + /// or paused/resumed/cancelled (TUS only). /// /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error /// is returned. Set `upsert: true` to overwrite silently instead. @@ -116,20 +120,39 @@ public struct StorageFileAPI: Sendable { /// ## Example /// /// ```swift + /// // Auto (default) — picks the right protocol based on size /// let response = try await storage.from("avatars").upload( /// "user-123/photo.jpg", /// data: imageData, /// options: FileOptions(contentType: "image/jpeg") /// ).value + /// + /// // Force TUS for a large video with pause/resume support + /// let task = storage.from("videos").upload("clip.mp4", data: videoData, method: .resumable) + /// await task.pause() + /// await task.resume() + /// let response = try await task.value /// ``` @discardableResult public func upload( _ path: String, data: Data, - options: FileOptions = FileOptions() + options: FileOptions = FileOptions(), + method: UploadMethod = .auto ) -> StorageUploadTask { - return MultipartUploadEngine.makeTask( - bucketId: bucketId, path: path, source: .data(data), options: options, client: client) + let useResumable: Bool + switch method { + case .auto: useResumable = data.count > client.configuration.tusChunkSize + case .multipart: useResumable = false + case .resumable: useResumable = true + } + if useResumable { + return TUSUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .data(data), options: options, client: client) + } else { + return MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .data(data), options: options, client: client) + } } /// Uploads a file from a local `URL` to an existing bucket. @@ -140,7 +163,12 @@ public struct StorageFileAPI: Sendable { /// - fileURL: A local `file://` URL pointing to the file to upload. /// - options: Upload options such as content type, cache duration, and upsert behaviour. /// When `contentType` is `nil`, the MIME type is inferred from the file extension. - /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress. + /// - method: The upload protocol to use. Defaults to ``UploadMethod/auto``, which picks + /// multipart for files ≤ 6 MB and TUS resumable for larger files. When the file size + /// cannot be determined, ``UploadMethod/auto`` falls back to TUS as a safe default. + /// Pass ``UploadMethod/multipart`` or ``UploadMethod/resumable`` to override. + /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress, + /// or paused/resumed/cancelled (TUS only). /// /// ## Example /// @@ -149,20 +177,40 @@ public struct StorageFileAPI: Sendable { /// "reports/2024/annual.pdf", /// fileURL: fileURL /// ).value + /// + /// // Explicit TUS for a large video + /// let task = storage.from("videos").upload("tour.mov", fileURL: movURL, method: .resumable) + /// let response = try await task.value /// ``` @discardableResult public func upload( _ path: String, fileURL: URL, - options: FileOptions = FileOptions() + options: FileOptions = FileOptions(), + method: UploadMethod = .auto ) -> StorageUploadTask { - return MultipartUploadEngine.makeTask( - bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client) + let useResumable: Bool + switch method { + case .auto: + let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? Int.max + useResumable = size > client.configuration.tusChunkSize + case .multipart: useResumable = false + case .resumable: useResumable = true + } + if useResumable { + return TUSUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client) + } else { + return MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client) + } } /// Replaces an existing file at the specified path with new `Data`. /// - /// Always sets `upsert: true` to overwrite the existing object. + /// Sends a `PUT` request to the storage server — the file must already exist. + /// To create a file if it does not exist, use ``upload(_:data:options:method:)`` with + /// ``FileOptions/upsert`` set to `true`. /// /// - Parameters: /// - path: The path of the file to replace, e.g. `"folder/image.png"`. @@ -185,14 +233,16 @@ public struct StorageFileAPI: Sendable { data: Data, options: FileOptions = FileOptions() ) -> StorageUploadTask { - var upsertOptions = options - upsertOptions.upsert = true - return upload(path, data: data, options: upsertOptions) + MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .data(data), options: options, + httpMethod: .put, client: client) } /// Replaces an existing file at the specified path with the contents of a local `URL`. /// - /// Always sets `upsert: true` to overwrite the existing object. + /// Sends a `PUT` request to the storage server — the file must already exist. + /// To create a file if it does not exist, use ``upload(_:fileURL:options:method:)`` with + /// ``FileOptions/upsert`` set to `true`. /// /// - Parameters: /// - path: The path of the file to replace, e.g. `"folder/image.png"`. @@ -215,10 +265,11 @@ public struct StorageFileAPI: Sendable { fileURL: URL, options: FileOptions = FileOptions() ) -> StorageUploadTask { - var upsertOptions = options - upsertOptions.upsert = true - return upload(path, fileURL: fileURL, options: upsertOptions) + MultipartUploadEngine.makeTask( + bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, + httpMethod: .put, client: client) } + /// Moves an existing file to a new path within the same or a different bucket. /// /// The source file is removed after the move completes. To keep the original, use ``copy(from:to:options:)`` instead. @@ -538,6 +589,113 @@ public struct StorageFileAPI: Sendable { body: .data(originalEncoder.encode(options)) ) } + + /// Downloads a file to a temporary location on disk. + /// + /// The task's success value is a `URL` pointing to a temporary file. Move or copy the file to a + /// permanent location before the app exits — the file is not guaranteed to persist across + /// launches. + /// + /// When ``StorageClientConfiguration/backgroundDownloadSessionIdentifier`` is set, downloads + /// continue while the app is suspended. Wire up + /// ``StorageClient/handleBackgroundEvents(forSessionIdentifier:completionHandler:)`` in your + /// `AppDelegate` to support background transfers. + /// + /// - Parameters: + /// - path: Path within the bucket, e.g. `"folder/image.png"`. + /// - options: Optional on-the-fly image transformation (resize, reformat, quality). + /// - query: Additional query items appended to the request URL. + /// - cacheNonce: An opaque string appended as `cacheNonce=` for cache busting. + /// - Returns: A ``StorageDownloadTask`` whose success value is a `URL` to the file on disk. + /// + /// ## Example + /// + /// ```swift + /// let url = try await storage.from("avatars").download(path: "user-123/photo.png").value + /// + /// // Move to a permanent location before the app exits + /// let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + /// .appendingPathComponent("photo.png") + /// try FileManager.default.moveItem(at: url, to: dest) + /// ``` + @discardableResult + public func download( + path: String, + options: TransformOptions? = nil, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> StorageDownloadTask { + let url = _downloadURL( + path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce) + // Use http.createRequest so the token provider is called before the + // URLSession download task is created — client.mergedHeaders alone strips + // the Authorization header when a token provider is configured. + return client.downloadDelegate.makeStorageDownloadTask(in: client.downloadSession) { + try await client.http.createRequest(.get, url: url, headers: client.mergedHeaders([:])) + } + } + + /// Downloads a file into memory as `Data`. + /// + /// The entire file is held in memory, so prefer ``download(path:options:)`` for large files or + /// when you need background transfer support. + /// + /// - Parameters: + /// - path: Path within the bucket, e.g. `"folder/image.png"`. + /// - options: Optional on-the-fly image transformation. + /// - query: Additional query items appended to the request URL. + /// - cacheNonce: An opaque string appended as `cacheNonce=` for cache busting. + /// - Returns: A task whose success value is the raw file bytes. + /// + /// ## Example + /// + /// ```swift + /// let data = try await storage.from("avatars").downloadData(path: "user-123/photo.png").value + /// let image = UIImage(data: data) + /// ``` + @discardableResult + public func downloadData( + path: String, + options: TransformOptions? = nil, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> StorageTransferTask { + download(path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce) + .mapResult { url in + defer { try? FileManager.default.removeItem(at: url) } + return try Data(contentsOf: url) + } + } + + private func _downloadURL( + path: String, + options: TransformOptions?, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> URL { + let finalPath = _getFinalPath(_removeEmptyFolders(path)) + + var queryItems = options?.queryItems ?? [] + if let additionalQueryItems { queryItems.append(contentsOf: additionalQueryItems) } + if let cacheNonce { queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce)) } + + let basePath = + options.map { !$0.isEmpty } == true + ? "render/image/authenticated/\(finalPath)" + : "object/authenticated/\(finalPath)" + + if queryItems.isEmpty { + return client.url.appendingPathComponent(basePath) + } + + var components = URLComponents( + url: client.url.appendingPathComponent(basePath), + resolvingAgainstBaseURL: false + ) + components?.queryItems = queryItems + return components?.url ?? client.url.appendingPathComponent(basePath) + } + /// Retrieves extended metadata for a file without downloading its contents. /// /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index 0b1bd9ab..0f6d724f 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -519,7 +519,7 @@ struct StorageFileAPITests { url: objectURL, contentType: .json, statusCode: 200, - data: [.post: Data(responseJSON.utf8)] + data: [.put: Data(responseJSON.utf8)] ).register() let response = try await storage.from("bucket") @@ -532,7 +532,7 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/file.txt") } - @Test func updateSetsUpsertTrue() async throws { + @Test func updateUsesPUTWithNoUpsertHeader() async throws { let objectURL = url.appendingPathComponent("object/bucket/file.txt") let responseJSON = """ {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} @@ -543,7 +543,7 @@ struct StorageFileAPITests { url: objectURL, contentType: .json, statusCode: 200, - data: [.post: Data(responseJSON.utf8)] + data: [.put: Data(responseJSON.utf8)] ) mock.onRequestHandler = OnRequestHandler(requestCallback: { request in capturedRequest.setValue(request) @@ -554,7 +554,8 @@ struct StorageFileAPITests { .update("file.txt", data: Data("hello".utf8)).value let request = try #require(capturedRequest.value) - #expect(request.value(forHTTPHeaderField: "x-upsert") == "true") + #expect(request.httpMethod == "PUT") + #expect(request.value(forHTTPHeaderField: "x-upsert") == nil) } @Test func updateFromURL() async throws { @@ -566,7 +567,7 @@ struct StorageFileAPITests { url: objectURL, contentType: .json, statusCode: 200, - data: [.post: Data(responseJSON.utf8)] + data: [.put: Data(responseJSON.utf8)] ).register() let response = try await storage.from("bucket") @@ -579,6 +580,17 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/file.txt") } + @Test func download() async { + let task = storage.from("bucket").download(path: "file.txt") + await task.cancel() + } + + @Test func download_withEmptyTransformOptions() async { + // Empty TransformOptions should still route to /object/authenticated/. + let task = storage.from("bucket").download(path: "file.txt", options: TransformOptions()) + await task.cancel() + } + @Test func getPublicURL_withEmptyTransformOptions() throws { let publicURL = try storage.from("bucket") .getPublicURL(path: "image.png", options: TransformOptions()) @@ -603,6 +615,16 @@ struct StorageFileAPITests { ) } + @Test func download_withOptions() async { + // Non-empty TransformOptions should route to /render/image/authenticated/. + let task = storage.from("bucket") + .download( + path: "sadcat.txt", + options: TransformOptions(format: .origin) + ) + await task.cancel() + } + @Test func info() async throws { Mock( url: url.appendingPathComponent("object/info/bucket/file.txt"), @@ -1030,6 +1052,91 @@ struct StorageFileAPITests { ) } + @Test func uploadEmitsProgressEvents() async throws { + let resumableURL = url.appendingPathComponent("upload/resumable") + let locationURL = url.appendingPathComponent( + "upload/resumable/YnVja2V0L2ZpbGUudHh0L2VhYThiZGI1LTJlMDAtNDc2Ny1iNWE5LWQyNTAyZWZlMjE5Ng") + + Mock( + url: resumableURL, + contentType: .json, + statusCode: 201, + data: [.post: Data()], + additionalHeaders: ["Location": locationURL.absoluteString] + ).register() + + Mock( + url: locationURL, + contentType: .json, + statusCode: 204, + data: [.patch: Data()], + additionalHeaders: ["Upload-Offset": "11"] + ).register() + + let task = storage.from("bucket").upload( + "file.txt", + data: Data("hello world".utf8) + ) + + let response = try await task.value + + #expect(response.id == UUID(uuidString: "eaa8bdb5-2e00-4767-b5a9-d2502efe2196")) + #expect(response.path == "file.txt") + #expect(response.fullPath == "bucket/file.txt") + } + + @Test func downloadData() async { + // downloadData is a convenience wrapper over download that maps the URL result to Data. + let task = storage.from("bucket").downloadData(path: "file.txt") + await task.cancel() + } + + // MARK: - method: .multipart + + @Test func uploadMultipartMethodFromData() async throws { + let objectURL = url.appendingPathComponent("object/bucket/file.txt") + let responseJSON = """ + {"Key":"bucket/file.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} + """ + Mock( + url: objectURL, + contentType: .json, + statusCode: 200, + data: [.post: Data(responseJSON.utf8)] + ).register() + + let response = try await storage.from("bucket") + .upload("file.txt", data: Data("hello world".utf8), method: .multipart).value + + #expect(response.path == "file.txt") + #expect(response.fullPath == "bucket/file.txt") + } + + // MARK: - method: .resumable + + @Test func uploadResumableMethodFromData() async throws { + let resumableURL = url.appendingPathComponent("upload/resumable") + let locationURL = url.appendingPathComponent( + "upload/resumable/YnVja2V0L2ZpbGUudHh0L2VhYThiZGI1LTJlMDAtNDc2Ny1iNWE5LWQyNTAyZWZlMjE5Ng") + + Mock( + url: resumableURL, contentType: .json, statusCode: 201, data: [.post: Data()], + additionalHeaders: ["Location": locationURL.absoluteString] + ).register() + Mock( + url: locationURL, contentType: .json, statusCode: 204, data: [.patch: Data()], + additionalHeaders: ["Upload-Offset": "5"] + ).register() + + let response = try await storage.from("bucket") + .upload("file.txt", data: Data("hello".utf8), method: .resumable).value + + #expect(response.path == "file.txt") + #expect(response.fullPath == "bucket/file.txt") + } + + // MARK: - Smart default + @Test func uploadSmallDataUsesMultipart() async throws { // 1 byte — well below 6 MB threshold → should POST to /object/... let objectURL = url.appendingPathComponent("object/bucket/small.txt") @@ -1048,7 +1155,7 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/small.txt") } - @Test func updateSmallDataUsesMultipart() async throws { + @Test func updateUsesMultipartPUT() async throws { let objectURL = url.appendingPathComponent("object/bucket/small.txt") let responseJSON = """ {"Key":"bucket/small.txt","Id":"EAA8BDB5-2E00-4767-B5A9-D2502EFE2196"} @@ -1056,7 +1163,7 @@ struct StorageFileAPITests { let capturedRequest = LockIsolated(nil) var mock = Mock( url: objectURL, contentType: .json, statusCode: 200, - data: [.post: Data(responseJSON.utf8)] + data: [.put: Data(responseJSON.utf8)] ) mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) mock.register() @@ -1065,6 +1172,7 @@ struct StorageFileAPITests { .update("small.txt", data: Data("x".utf8)).value let req = try #require(capturedRequest.value) - #expect(req.value(forHTTPHeaderField: "x-upsert") == "true") + #expect(req.httpMethod == "PUT") + #expect(req.value(forHTTPHeaderField: "x-upsert") == nil) } }