diff --git a/.swiftpm/configuration/Package.resolved b/.swiftpm/configuration/Package.resolved index dee6efd6a..5fbb03482 100644 --- a/.swiftpm/configuration/Package.resolved +++ b/.swiftpm/configuration/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "multipartformdata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grdsdev/MultipartFormData", + "state" : { + "revision" : "ed7abea9cfc6c3b5e77a73fe6842c57a372d2017", + "version" : "0.1.0" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", diff --git a/Examples/Examples/Storage/FileObjectDetailView.swift b/Examples/Examples/Storage/FileObjectDetailView.swift index 8c21faafe..48d483b37 100644 --- a/Examples/Examples/Storage/FileObjectDetailView.swift +++ b/Examples/Examples/Storage/FileObjectDetailView.swift @@ -45,6 +45,15 @@ struct FileObjectDetailView: View { } catch {} } } + + Button("Get info") { + Task { + do { + let info = try await api.info(path: fileObject.name) + lastActionResult = ("info", info) + } catch {} + } + } } if let lastActionResult { diff --git a/Package.swift b/Package.swift index 95e9b7d63..3e5822151 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "Supabase", targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"]), ], dependencies: [ + .package(url: "https://github.com/grdsdev/MultipartFormData", from: "0.1.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), @@ -127,7 +128,13 @@ let package = Package( "TestHelpers", ] ), - .target(name: "Storage", dependencies: ["Helpers"]), + .target( + name: "Storage", + dependencies: [ + "MultipartFormData", + "Helpers", + ] + ), .testTarget( name: "StorageTests", dependencies: [ diff --git a/Sources/Storage/Deprecated.swift b/Sources/Storage/Deprecated.swift index d5ec8d5d0..865328b94 100644 --- a/Sources/Storage/Deprecated.swift +++ b/Sources/Storage/Deprecated.swift @@ -65,4 +65,109 @@ extension StorageFileApi { ) async throws -> String { try await uploadToSignedURL(path: path, token: token, file: file, options: options).fullPath } + + @available(*, deprecated, renamed: "upload(_:data:options:)") + @discardableResult + public func upload( + path: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> FileUploadResponse { + try await upload(path, data: file, options: options) + } + + @available(*, deprecated, renamed: "update(_:data:options:)") + @discardableResult + public func update( + path: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> FileUploadResponse { + try await update(path, data: file, options: options) + } + + @available(*, deprecated, renamed: "updateToSignedURL(_:token:data:options:)") + @discardableResult + public func uploadToSignedURL( + path: String, + token: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> SignedURLUploadResponse { + try await uploadToSignedURL(path, token: token, data: file, options: options) + } +} + +@available( + *, + deprecated, + message: "File was deprecated and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." +) +public struct File: Hashable, Equatable { + public var name: String + public var data: Data + public var fileName: String? + public var contentType: String? + + public init(name: String, data: Data, fileName: String?, contentType: String?) { + self.name = name + self.data = data + self.fileName = fileName + self.contentType = contentType + } +} + +@available( + *, + deprecated, + renamed: "MultipartFormData", + message: "FormData was deprecated in favor of MultipartFormData, and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." +) +public class FormData { + var files: [File] = [] + var boundary: String + + public init(boundary: String = UUID().uuidString) { + self.boundary = boundary + } + + public func append(file: File) { + files.append(file) + } + + public var contentType: String { + "multipart/form-data; boundary=\(boundary)" + } + + public var data: Data { + var data = Data() + + for file in files { + data.append("--\(boundary)\r\n") + data.append("Content-Disposition: form-data; name=\"\(file.name)\"") + if let filename = file.fileName?.replacingOccurrences(of: "\"", with: "_") { + data.append("; filename=\"\(filename)\"") + } + data.append("\r\n") + if let contentType = file.contentType { + data.append("Content-Type: \(contentType)\r\n") + } + data.append("\r\n") + data.append(file.data) + data.append("\r\n") + } + + data.append("--\(boundary)--\r\n") + return data + } +} + +extension Data { + mutating func append(_ string: String) { + let data = string.data( + using: String.Encoding.utf8, + allowLossyConversion: true + ) + append(data!) + } } diff --git a/Sources/Storage/Helpers.swift b/Sources/Storage/Helpers.swift index 3bbdf0d42..382d7250b 100644 --- a/Sources/Storage/Helpers.swift +++ b/Sources/Storage/Helpers.swift @@ -7,38 +7,67 @@ import Foundation -#if canImport(CoreServices) +#if canImport(MobileCoreServices) + import MobileCoreServices +#elseif canImport(CoreServices) import CoreServices #endif #if canImport(UniformTypeIdentifiers) import UniformTypeIdentifiers -#endif -#if os(Linux) || os(Windows) - /// On Linux or Windows this method always returns `application/octet-stream`. - func mimeTypeForExtension(_: String) -> String { - "application/octet-stream" + func mimeType(forPathExtension pathExtension: String) -> String { + #if swift(>=5.9) + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + + return "application/octet-stream" + } + #else + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, *) { + return UTType(filenameExtension: pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + } else { + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + + return "application/octet-stream" + } + #endif } #else - func mimeTypeForExtension(_ fileExtension: String) -> String { - if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, visionOS 1.0, *) { - return UTType(filenameExtension: fileExtension)?.preferredMIMEType ?? "application/octet-stream" - } else { - guard - let type = UTTypeCreatePreferredIdentifierForTag( - kUTTagClassFilenameExtension, - fileExtension as NSString, - nil - )?.takeUnretainedValue(), - let mimeType = UTTypeCopyPreferredTagWithClass( - type, - kUTTagClassMIMEType - )?.takeUnretainedValue() - else { return "application/octet-stream" } - - return mimeType as String - } + + // MARK: - Private - Mime Type + + func mimeType(forPathExtension pathExtension: String) -> String { + #if canImport(CoreServices) || canImport(MobileCoreServices) + if let id = UTTypeCreatePreferredIdentifierForTag( + kUTTagClassFilenameExtension, pathExtension as CFString, nil + )?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? + .takeRetainedValue() + { + return contentType as String + } + #endif + + return "application/octet-stream" } #endif diff --git a/Sources/Storage/MultipartFile.swift b/Sources/Storage/MultipartFile.swift deleted file mode 100644 index ad98d41f1..000000000 --- a/Sources/Storage/MultipartFile.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation - -public struct File: Hashable, Equatable { - public var name: String - public var data: Data - public var fileName: String? - public var contentType: String? - - public init(name: String, data: Data, fileName: String?, contentType: String?) { - self.name = name - self.data = data - self.fileName = fileName - self.contentType = contentType - } -} - -public class FormData { - var files: [File] = [] - var boundary: String - - public init(boundary: String = UUID().uuidString) { - self.boundary = boundary - } - - public func append(file: File) { - files.append(file) - } - - public var contentType: String { - "multipart/form-data; boundary=\(boundary)" - } - - public var data: Data { - var data = Data() - - for file in files { - data.append("--\(boundary)\r\n") - data.append("Content-Disposition: form-data; name=\"\(file.name)\"") - if let filename = file.fileName?.replacingOccurrences(of: "\"", with: "_") { - data.append("; filename=\"\(filename)\"") - } - data.append("\r\n") - if let contentType = file.contentType { - data.append("Content-Type: \(contentType)\r\n") - } - data.append("\r\n") - data.append(file.data) - data.append("\r\n") - } - - data.append("--\(boundary)--\r\n") - return data - } -} - -extension Data { - mutating func append(_ string: String) { - let data = string.data( - using: String.Encoding.utf8, - allowLossyConversion: true - ) - append(data!) - } -} diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index 73a98880f..e2ad1fb5f 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -1,5 +1,6 @@ import Foundation import Helpers +import class MultipartFormData.MultipartFormData #if canImport(FoundationNetworking) import FoundationNetworking @@ -36,7 +37,10 @@ public class StorageApi: @unchecked Sendable { let response = try await http.send(request) guard (200 ..< 300).contains(response.statusCode) else { - if let error = try? configuration.decoder.decode(StorageError.self, from: response.data) { + if let error = try? configuration.decoder.decode( + StorageError.self, + from: response.data + ) { throw error } @@ -52,10 +56,10 @@ extension HTTPRequest { url: URL, method: HTTPMethod, query: [URLQueryItem], - formData: FormData, + formData: MultipartFormData, options: FileOptions, headers: HTTPHeaders = [:] - ) { + ) throws { var headers = headers if headers["Content-Type"] == nil { headers["Content-Type"] = formData.contentType @@ -63,12 +67,12 @@ extension HTTPRequest { if headers["Cache-Control"] == nil { headers["Cache-Control"] = "max-age=\(options.cacheControl)" } - self.init( + try self.init( url: url, method: method, query: query, headers: headers, - body: formData.data + body: formData.encode() ) } } diff --git a/Sources/Storage/StorageBucketApi.swift b/Sources/Storage/StorageBucketApi.swift index 3351be887..61473a1af 100644 --- a/Sources/Storage/StorageBucketApi.swift +++ b/Sources/Storage/StorageBucketApi.swift @@ -6,7 +6,7 @@ import Helpers #endif /// Storage Bucket API -public class StorageBucketApi: StorageApi { +public class StorageBucketApi: StorageApi, @unchecked Sendable { /// Retrieves the details of all Storage buckets within an existing product. public func listBuckets() async throws -> [Bucket] { try await execute( diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index 8093464c6..5022a9d1a 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -1,5 +1,6 @@ import Foundation import Helpers +import class MultipartFormData.MultipartFormData #if canImport(FoundationNetworking) import FoundationNetworking @@ -14,10 +15,16 @@ let DEFAULT_SEARCH_OPTIONS = SearchOptions( ) ) +private let defaultFileOptions = FileOptions( + cacheControl: "3600", + contentType: "text/plain;charset=UTF-8", + upsert: false +) + /// Supabase Storage File API -public class StorageFileApi: StorageApi { +public class StorageFileApi: StorageApi, @unchecked Sendable { /// The bucket id to operate on. - var bucketId: String + let bucketId: String init(bucketId: String, configuration: StorageClientConfiguration) { self.bucketId = bucketId @@ -32,24 +39,35 @@ public class StorageFileApi: StorageApi { let signedURL: URL } - func uploadOrUpdate( + private func encodeMetadata(_ metadata: JSONObject) -> Data { + let encoder = AnyJSON.encoder + return (try? encoder.encode(metadata)) ?? "{}".data(using: .utf8)! + } + + private func _uploadOrUpdate( method: HTTPMethod, path: String, - file: Data, - options: FileOptions + formData: MultipartFormData, + options: FileOptions? ) async throws -> FileUploadResponse { - let contentType = options.contentType ?? mimeTypeForExtension(path.pathExtension) - var headers = HTTPHeaders([ - "x-upsert": "\(options.upsert)", - ]) + let options = options ?? defaultFileOptions + var headers = options.headers.map { HTTPHeaders($0) } ?? HTTPHeaders() + + let metadata = options.metadata + + if method == .post { + headers.update(name: "x-upsert", value: "\(options.upsert)") + } headers["duplex"] = options.duplex - let fileName = path.fileName + if let metadata { + formData.append(encodeMetadata(metadata), withName: "metadata") + } - let form = FormData() - form.append( - file: File(name: fileName, data: file, fileName: fileName, contentType: contentType) + formData.append( + options.cacheControl.data(using: .utf8)!, + withName: "cacheControl" ) struct UploadResponse: Decodable { @@ -57,12 +75,15 @@ public class StorageFileApi: StorageApi { let Id: String } + let cleanPath = _removeEmptyFolders(path) + let _path = _getFinalPath(cleanPath) + let response = try await execute( HTTPRequest( - url: configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"), + url: configuration.url.appendingPathComponent("object/\(_path)"), method: method, query: [], - formData: form, + formData: formData, options: options, headers: headers ) @@ -80,30 +101,75 @@ public class StorageFileApi: StorageApi { /// - Parameters: /// - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The /// bucket must already exist before attempting to upload. - /// - file: The Data to be stored in the bucket. + /// - data: The Data to be stored in the bucket. /// - options: HTTP headers. For example `cacheControl` @discardableResult public func upload( - path: String, - file: Data, + _ path: String, + data: Data, + options: FileOptions = FileOptions() + ) async throws -> FileUploadResponse { + let fileName = path.fileName + let formData = MultipartFormData() + formData.append( + data, + withName: fileName, + fileName: fileName, + mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension) + ) + return try await _uploadOrUpdate(method: .post, path: path, formData: formData, options: options) + } + + @discardableResult + public func upload( + _ path: String, + fileURL: Data, + options: FileOptions = FileOptions() + ) async throws -> FileUploadResponse { + let fileName = path.fileName + let formData = MultipartFormData() + formData.append(fileURL, withName: fileName, fileName: fileName) + return try await _uploadOrUpdate(method: .post, path: path, formData: formData, options: options) + } + + /// Replaces an existing file at the specified path with a new one. + /// - Parameters: + /// - path: The relative file path. Should be of the format `folder/subfolder`. The bucket + /// already exist before attempting to upload. + /// - data: The Data to be stored in the bucket. + /// - options: HTTP headers. For example `cacheControl` + @discardableResult + public func update( + _ path: String, + data: Data, options: FileOptions = FileOptions() ) async throws -> FileUploadResponse { - try await uploadOrUpdate(method: .post, path: path, file: file, options: options) + let fileName = path.fileName + let formData = MultipartFormData() + formData.append( + data, + withName: fileName, + fileName: fileName, + mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension) + ) + return try await _uploadOrUpdate(method: .put, path: path, formData: formData, options: options) } /// Replaces an existing file at the specified path with a new one. /// - Parameters: /// - path: The relative file path. Should be of the format `folder/subfolder`. The bucket /// already exist before attempting to upload. - /// - file: The Data to be stored in the bucket. + /// - fileURL: The file URL to be stored in the bucket. /// - options: HTTP headers. For example `cacheControl` @discardableResult public func update( - path: String, - file: Data, + _ path: String, + fileURL: URL, options: FileOptions = FileOptions() ) async throws -> FileUploadResponse { - try await uploadOrUpdate(method: .put, path: path, file: file, options: options) + let formData = MultipartFormData() + formData.append(fileURL, withName: path.fileName) + return try await _uploadOrUpdate(method: .put, path: path, formData: formData, options: options) } /// Moves an existing file, optionally renaming it at the same time. @@ -358,6 +424,46 @@ public class StorageFileApi: StorageApi { .data } + /// Retrieves the details of an existing file. + public func info(path: String) async throws -> FileObjectV2 { + let _path = _getFinalPath(path) + + return try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("object/info/\(_path)"), + method: .get + ) + ) + .decoded(decoder: configuration.decoder) + } + + /// Checks the existence of file. + public func exists(path: String) async throws -> Bool { + do { + try await execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"), + method: .head + ) + ) + return true + } catch { + var statusCode: Int? + + if let error = error as? StorageError { + statusCode = error.statusCode.flatMap(Int.init) + } else if let error = error as? HTTPError { + statusCode = error.response.statusCode + } + + if let statusCode, [400, 404].contains(statusCode) { + return false + } + + throw error + } + } + /// Returns a public url for an asset. /// - Parameters: /// - path: The file path to the asset. For example `folder/image.png`. @@ -461,34 +567,75 @@ public class StorageFileApi: StorageApi { /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``. /// - Parameters: - /// - path: The file path, including the file name. Should be of the format - /// `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. + /// - path: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. /// - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``. - /// - file: The Data to be stored in the bucket. + /// - data: The Data to be stored in the bucket. /// - options: HTTP headers, for example `cacheControl`. /// - Returns: A key pointing to stored location. @discardableResult public func uploadToSignedURL( + _ path: String, + token: String, + data: Data, + options: FileOptions? = nil + ) async throws -> SignedURLUploadResponse { + let fileName = path.fileName + let formData = MultipartFormData() + formData.append( + data, + withName: fileName, + fileName: fileName, + mimeType: options?.contentType ?? mimeType(forPathExtension: path.pathExtension) + ) + return try await _uploadToSignedURL( + path: path, + token: token, + formData: formData, + options: options + ) + } + + /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``. + /// - Parameters: + /// - path: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. + /// - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``. + /// - fileURL: The file URL to be stored in the bucket. + /// - options: HTTP headers, for example `cacheControl`. + /// - Returns: A key pointing to stored location. + @discardableResult + public func uploadToSignedURL( + _ path: String, + token: String, + fileURL: Data, + options: FileOptions? = nil + ) async throws -> SignedURLUploadResponse { + let formData = MultipartFormData() + formData.append(fileURL, withName: path.fileName) + return try await _uploadToSignedURL( + path: path, + token: token, + formData: formData, + options: options + ) + } + + private func _uploadToSignedURL( path: String, token: String, - file: Data, - options: FileOptions = FileOptions() + formData: MultipartFormData, + options: FileOptions? ) async throws -> SignedURLUploadResponse { - let contentType = options.contentType ?? mimeTypeForExtension(path.pathExtension) - var headers = HTTPHeaders([ - "x-upsert": "\(options.upsert)", - ]) + let options = options ?? defaultFileOptions + var headers = options.headers.map { HTTPHeaders($0) } ?? HTTPHeaders() + + headers["x-upsert"] = "\(options.upsert)" headers["duplex"] = options.duplex - let fileName = path.fileName + if let metadata = options.metadata { + formData.append(encodeMetadata(metadata), withName: "metadata") + } - let form = FormData() - form.append(file: File( - name: fileName, - data: file, - fileName: fileName, - contentType: contentType - )) + formData.append(options.cacheControl.data(using: .utf8)!, withName: "cacheControl") struct UploadResponse: Decodable { let Key: String @@ -500,7 +647,7 @@ public class StorageFileApi: StorageApi { .appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"), method: .put, query: [URLQueryItem(name: "token", value: token)], - formData: form, + formData: formData, options: options, headers: headers ) @@ -510,4 +657,14 @@ public class StorageFileApi: StorageApi { return SignedURLUploadResponse(path: path, fullPath: fullPath) } + + private func _getFinalPath(_ path: String) -> String { + "\(bucketId)/\(path)" + } + + private func _removeEmptyFolders(_ path: String) -> String { + let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let cleanedPath = trimmedPath.replacingOccurrences(of: "/+", with: "/", options: .regularExpression) + return cleanedPath + } } diff --git a/Sources/Storage/SupabaseStorage.swift b/Sources/Storage/SupabaseStorage.swift index b2cad2396..ed57a66e3 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -4,7 +4,7 @@ import Helpers public typealias SupabaseLogger = Helpers.SupabaseLogger public typealias SupabaseLogMessage = Helpers.SupabaseLogMessage -public struct StorageClientConfiguration { +public struct StorageClientConfiguration: Sendable { public let url: URL public var headers: [String: String] public let encoder: JSONEncoder @@ -29,7 +29,7 @@ public struct StorageClientConfiguration { } } -public class SupabaseStorageClient: StorageBucketApi { +public class SupabaseStorageClient: StorageBucketApi, @unchecked Sendable { /// Perform file operation in a bucket. /// - Parameter id: The bucket id to operate on. /// - Returns: StorageFileApi object diff --git a/Sources/Storage/Types.swift b/Sources/Storage/Types.swift index 14e36edfd..82874c2fe 100644 --- a/Sources/Storage/Types.swift +++ b/Sources/Storage/Types.swift @@ -57,16 +57,28 @@ public struct FileOptions: Sendable { /// fetch() method. public var duplex: String? + /// The metadata option is an object that allows you to store additional information about the file. + /// This information can be used to filter and search for files. + /// The metadata object can contain any key-value pairs you want to store. + public var metadata: [String: AnyJSON]? + + /// Optionally add extra headers. + public var headers: [String: String]? + public init( cacheControl: String = "3600", contentType: String? = nil, upsert: Bool = false, - duplex: String? = nil + duplex: String? = nil, + metadata: [String: AnyJSON]? = nil, + headers: [String: String]? = nil ) { self.cacheControl = cacheControl self.contentType = contentType self.upsert = upsert self.duplex = duplex + self.metadata = metadata + self.headers = headers } } @@ -166,6 +178,38 @@ public struct FileObject: Identifiable, Hashable, Codable, Sendable { } } +public struct FileObjectV2: Identifiable, Hashable, Decodable, Sendable { + public let id: String + public let version: String + public let name: String + public let bucketId: String? + public let updatedAt: Date? + public let createdAt: Date? + public let lastAccessedAt: Date? + public let size: Int? + public let cacheControl: String? + public let contentType: String? + public let etag: String? + public let lastModified: Date? + public let metadata: [String: AnyJSON]? + + enum CodingKeys: String, CodingKey { + case id + case version + case name + case bucketId = "bucket_id" + case updatedAt = "updated_at" + case createdAt = "created_at" + case lastAccessedAt = "last_accessed_at" + case size + case cacheControl = "cache_control" + case contentType = "content_type" + case etag + case lastModified = "last_modified" + case metadata + } +} + public struct Bucket: Identifiable, Hashable, Codable, Sendable { public var id: String public var name: String diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 52f217aa0..a92350636 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -36,6 +36,15 @@ "version" : "4.1.1" } }, + { + "identity" : "multipartformdata", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grdsdev/MultipartFormData", + "state" : { + "revision" : "ed7abea9cfc6c3b5e77a73fe6842c57a372d2017", + "version" : "0.1.0" + } + }, { "identity" : "svgview", "kind" : "remoteSourceControl", diff --git a/Tests/IntegrationTests/StorageFileIntegrationTests.swift b/Tests/IntegrationTests/StorageFileIntegrationTests.swift index 21e6206e5..f2ae953ec 100644 --- a/Tests/IntegrationTests/StorageFileIntegrationTests.swift +++ b/Tests/IntegrationTests/StorageFileIntegrationTests.swift @@ -64,7 +64,7 @@ final class StorageFileIntegrationTests: XCTestCase { } func testSignURL() async throws { - _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + _ = try await storage.from(bucketName).upload(uploadPath, data: file) let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000) XCTAssertTrue( @@ -73,7 +73,7 @@ final class StorageFileIntegrationTests: XCTestCase { } func testSignURL_withDownloadQueryString() async throws { - _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + _ = try await storage.from(bucketName).upload(uploadPath, data: file) let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000, download: true) XCTAssertTrue( @@ -83,7 +83,7 @@ final class StorageFileIntegrationTests: XCTestCase { } func testSignURL_withCustomFilenameForDownload() async throws { - _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + _ = try await storage.from(bucketName).upload(uploadPath, data: file) let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000, download: "test.jpg") XCTAssertTrue( @@ -95,9 +95,9 @@ final class StorageFileIntegrationTests: XCTestCase { func testUploadAndUpdateFile() async throws { let file2 = try Data(contentsOf: uploadFileURL("file-2.txt")) - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) - let res = try await storage.from(bucketName).update(path: uploadPath, file: file2) + let res = try await storage.from(bucketName).update(uploadPath, data: file2) XCTAssertEqual(res.path, uploadPath) } @@ -107,7 +107,7 @@ final class StorageFileIntegrationTests: XCTestCase { options: BucketOptions(public: true, fileSizeLimit: "1mb") ) - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) } func testUploadFileThatExceedFileSizeLimit() async throws { @@ -117,7 +117,7 @@ final class StorageFileIntegrationTests: XCTestCase { ) do { - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) XCTFail("Unexpected success") } catch { assertInlineSnapshot(of: error, as: .dump) { @@ -141,8 +141,8 @@ final class StorageFileIntegrationTests: XCTestCase { ) try await storage.from(bucketName).upload( - path: uploadPath, - file: file, + uploadPath, + data: file, options: FileOptions( contentType: "image/jpeg" ) @@ -157,8 +157,8 @@ final class StorageFileIntegrationTests: XCTestCase { do { try await storage.from(bucketName).upload( - path: uploadPath, - file: file, + uploadPath, + data: file, options: FileOptions( contentType: "image/jpeg" ) @@ -192,25 +192,25 @@ final class StorageFileIntegrationTests: XCTestCase { func testCanUploadWithSignedURLForUpload() async throws { let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) - let uploadRes = try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + 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(path: uploadPath, file: file) + 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(path: res.path, token: res.token, file: file) + 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(path: res.path, token: res.token, file: file) + try await storage.from(bucketName).uploadToSignedURL(res.path, token: res.token, data: file) do { - try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + try await storage.from(bucketName).uploadToSignedURL(res.path, token: res.token, data: file) XCTFail("Unexpected success") } catch { assertInlineSnapshot(of: error, as: .dump) { @@ -228,7 +228,7 @@ final class StorageFileIntegrationTests: XCTestCase { } func testListObjects() async throws { - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) let res = try await storage.from(bucketName).list(path: "testpath") XCTAssertEqual(res.count, 1) @@ -237,7 +237,7 @@ final class StorageFileIntegrationTests: XCTestCase { func testMoveObjectToDifferentPath() async throws { let newPath = "testpath/file-moved-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) try await storage.from(bucketName).move(from: uploadPath, to: newPath) } @@ -247,7 +247,7 @@ final class StorageFileIntegrationTests: XCTestCase { try await findOrCreateBucket(name: newBucketName) let newPath = "testpath/file-to-move-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) try await storage.from(bucketName).move( from: uploadPath, @@ -260,7 +260,7 @@ final class StorageFileIntegrationTests: XCTestCase { func testCopyObjectToDifferentPath() async throws { let newPath = "testpath/file-moved-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) try await storage.from(bucketName).copy(from: uploadPath, to: newPath) } @@ -270,7 +270,7 @@ final class StorageFileIntegrationTests: XCTestCase { try await findOrCreateBucket(name: newBucketName) let newPath = "testpath/file-to-copy-\(UUID().uuidString).txt" - try await storage.from(bucketName).upload(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) try await storage.from(bucketName).copy( from: uploadPath, @@ -282,14 +282,14 @@ final class StorageFileIntegrationTests: XCTestCase { } func testDownloadsAnObject() async throws { - try await storage.from(bucketName).upload(path: uploadPath, file: file) + 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(path: uploadPath, file: file) + try await storage.from(bucketName).upload(uploadPath, data: file) let res = try await storage.from(bucketName).remove(paths: [uploadPath]) XCTAssertEqual(res.count, 1) @@ -315,7 +315,7 @@ final class StorageFileIntegrationTests: XCTestCase { func testCreateAndLoadEmptyFolder() async throws { let path = "empty-folder/.placeholder" - try await storage.from(bucketName).upload(path: path, file: Data()) + try await storage.from(bucketName).upload(path, data: Data()) let files = try await storage.from(bucketName).list() assertInlineSnapshot(of: files, as: .json) { @@ -329,6 +329,30 @@ final class StorageFileIntegrationTests: XCTestCase { } } + 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) + } + private func newBucket( prefix: String = "", options: BucketOptions = BucketOptions(public: true)