diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index e9b6a03a..3609953e 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -29,6 +29,10 @@ 7962989D2AEBC6F9000AA957 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = 7962989C2AEBC6F9000AA957 /* SVGView */; }; 79719ECE2ADF26C400737804 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 79719ECD2ADF26C400737804 /* Supabase */; }; 797D664A2B46A1D8007592ED /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797D66492B46A1D8007592ED /* Dependencies.swift */; }; + 797EFB662BABD82A00098D6B /* BucketList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EFB652BABD82A00098D6B /* BucketList.swift */; }; + 797EFB682BABD90500098D6B /* Stringfy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EFB672BABD90500098D6B /* Stringfy.swift */; }; + 797EFB6A2BABDF3800098D6B /* BucketDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EFB692BABDF3800098D6B /* BucketDetailView.swift */; }; + 797EFB6C2BABE1B800098D6B /* FileObjectDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EFB6B2BABE1B800098D6B /* FileObjectDetailView.swift */; }; 7993B8A92B3C673A009B610B /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7993B8A82B3C673A009B610B /* AuthView.swift */; }; 7993B8AB2B3C67E0009B610B /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7993B8AA2B3C67E0009B610B /* Toast.swift */; }; 79AF047F2B2CE207008761AD /* AuthWithEmailAndPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79AF047E2B2CE207008761AD /* AuthWithEmailAndPassword.swift */; }; @@ -87,6 +91,10 @@ 796298982AEBBA77000AA957 /* MFAFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFAFlow.swift; sourceTree = ""; }; 7962989A2AEBBD9F000AA957 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 797D66492B46A1D8007592ED /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + 797EFB652BABD82A00098D6B /* BucketList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketList.swift; sourceTree = ""; }; + 797EFB672BABD90500098D6B /* Stringfy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stringfy.swift; sourceTree = ""; }; + 797EFB692BABDF3800098D6B /* BucketDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketDetailView.swift; sourceTree = ""; }; + 797EFB6B2BABE1B800098D6B /* FileObjectDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileObjectDetailView.swift; sourceTree = ""; }; 7993B8A82B3C673A009B610B /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; 7993B8AA2B3C67E0009B610B /* Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 7993B8AC2B3C97B6009B610B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -182,6 +190,7 @@ 793895C82954ABFF0044F2B8 /* Examples */ = { isa = PBXGroup; children = ( + 797EFB642BABD7FF00098D6B /* Storage */, 79AF04822B2CE3BD008761AD /* Auth */, 7962989A2AEBBD9F000AA957 /* Info.plist */, 793895CD2954AC000044F2B8 /* Assets.xcassets */, @@ -201,6 +210,7 @@ 793E03082B2CED5D00AC7DED /* Contants.swift */, 793E030A2B2CEDDA00AC7DED /* ActionState.swift */, 79E2B55B2B97A2310042CD21 /* UIApplicationExtensions.swift */, + 797EFB672BABD90500098D6B /* Stringfy.swift */, ); path = Examples; sourceTree = ""; @@ -220,6 +230,16 @@ name = Frameworks; sourceTree = ""; }; + 797EFB642BABD7FF00098D6B /* Storage */ = { + isa = PBXGroup; + children = ( + 797EFB652BABD82A00098D6B /* BucketList.swift */, + 797EFB692BABDF3800098D6B /* BucketDetailView.swift */, + 797EFB6B2BABE1B800098D6B /* FileObjectDetailView.swift */, + ); + path = Storage; + sourceTree = ""; + }; 79AF04822B2CE3BD008761AD /* Auth */ = { isa = PBXGroup; children = ( @@ -452,6 +472,7 @@ 7956406A2955AFBD0088A06F /* ErrorText.swift in Sources */, 79AF04812B2CE261008761AD /* AuthView.swift in Sources */, 794EF1242955F3DE008C9526 /* TodoListRow.swift in Sources */, + 797EFB662BABD82A00098D6B /* BucketList.swift in Sources */, 79E2B55C2B97A2310042CD21 /* UIApplicationExtensions.swift in Sources */, 794EF1222955F26A008C9526 /* AddTodoListView.swift in Sources */, 7956405E2954ADE00088A06F /* Secrets.swift in Sources */, @@ -462,6 +483,9 @@ 795640622955AD2B0088A06F /* HomeView.swift in Sources */, 7940E3152B36187A0089BEE1 /* GoogleSignInWithWebFlow.swift in Sources */, 793895CA2954ABFF0044F2B8 /* ExamplesApp.swift in Sources */, + 797EFB682BABD90500098D6B /* Stringfy.swift in Sources */, + 797EFB6C2BABE1B800098D6B /* FileObjectDetailView.swift in Sources */, + 797EFB6A2BABDF3800098D6B /* BucketDetailView.swift in Sources */, 793E030D2B2DAB5700AC7DED /* SignInWithApple.swift in Sources */, 793E030B2B2CEDDA00AC7DED /* ActionState.swift in Sources */, ); diff --git a/Examples/Examples/Auth/AuthView.swift b/Examples/Examples/Auth/AuthView.swift index 37108abf..dfcfb9c8 100644 --- a/Examples/Examples/Auth/AuthView.swift +++ b/Examples/Examples/Auth/AuthView.swift @@ -27,16 +27,18 @@ struct AuthView: View { } var body: some View { - List { - ForEach(Option.allCases, id: \.self) { option in - NavigationLink(option.title, value: option) + NavigationStack { + List { + ForEach(Option.allCases, id: \.self) { option in + NavigationLink(option.title, value: option) + } } + .navigationDestination(for: Option.self) { options in + options + .navigationTitle(options.title) + } + .navigationBarTitleDisplayMode(.inline) } - .navigationDestination(for: Option.self) { options in - options - .navigationTitle(options.title) - } - .navigationBarTitleDisplayMode(.inline) } } diff --git a/Examples/Examples/Auth/AuthWithEmailAndPassword.swift b/Examples/Examples/Auth/AuthWithEmailAndPassword.swift index 7c9a0803..5a0fe82f 100644 --- a/Examples/Examples/Auth/AuthWithEmailAndPassword.swift +++ b/Examples/Examples/Auth/AuthWithEmailAndPassword.swift @@ -46,10 +46,6 @@ struct AuthWithEmailAndPassword: View { } } - if case let .result(.failure(error)) = actionState { - ErrorText(error) - } - switch actionState { case .idle: EmptyView() @@ -86,6 +82,7 @@ struct AuthWithEmailAndPassword: View { .animation(.default, value: mode) } + @MainActor func primaryActionButtonTapped() async { do { actionState = .inFlight @@ -110,6 +107,7 @@ struct AuthWithEmailAndPassword: View { } } + @MainActor private func onOpenURL(_ url: URL) async { do { try await supabase.auth.session(from: url) @@ -118,6 +116,7 @@ struct AuthWithEmailAndPassword: View { } } + @MainActor private func resendConfirmationButtonTapped() async { do { try await supabase.auth.resend(email: email, type: .signup) diff --git a/Examples/Examples/ExamplesApp.swift b/Examples/Examples/ExamplesApp.swift index 12975b08..a79fe15a 100644 --- a/Examples/Examples/ExamplesApp.swift +++ b/Examples/Examples/ExamplesApp.swift @@ -22,8 +22,11 @@ struct ExamplesApp: App { let supabase = SupabaseClient( supabaseURL: Secrets.supabaseURL, supabaseKey: Secrets.supabaseAnonKey, - options: .init(auth: .init(storage: KeychainLocalStorage( - service: "supabase.gotrue.swift", - accessGroup: nil - ))) + options: .init(global: .init(logger: ConsoleLogger())) ) + +struct ConsoleLogger: SupabaseLogger { + func log(message: SupabaseLogMessage) { + print(message) + } +} diff --git a/Examples/Examples/HomeView.swift b/Examples/Examples/HomeView.swift index 66e542a9..2f70c151 100644 --- a/Examples/Examples/HomeView.swift +++ b/Examples/Examples/HomeView.swift @@ -5,6 +5,7 @@ // Created by Guilherme Souza on 23/12/22. // +import Supabase import SwiftUI struct HomeView: View { @@ -13,28 +14,31 @@ struct HomeView: View { @State private var mfaStatus: MFAStatus? var body: some View { - TodoListView() - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button("Sign out") { - Task { - try! await supabase.auth.signOut() - } + NavigationStack { + BucketList() + .navigationDestination(for: Bucket.self, destination: BucketDetailView.init) + } + .toolbar { + ToolbarItemGroup(placement: .cancellationAction) { + Button("Sign out") { + Task { + try! await supabase.auth.signOut() } + } - Button("Reauthenticate") { - Task { - try! await supabase.auth.reauthenticate() - } + Button("Reauthenticate") { + Task { + try! await supabase.auth.reauthenticate() } } } - .task { + } + .task { // mfaStatus = await verifyMFAStatus() - } - .sheet(unwrapping: $mfaStatus) { $mfaStatus in - MFAFlow(status: mfaStatus) - } + } + .sheet(unwrapping: $mfaStatus) { $mfaStatus in + MFAFlow(status: mfaStatus) + } } private func verifyMFAStatus() async -> MFAStatus? { diff --git a/Examples/Examples/RootView.swift b/Examples/Examples/RootView.swift index 9bb1c30c..18e0592f 100644 --- a/Examples/Examples/RootView.swift +++ b/Examples/Examples/RootView.swift @@ -12,12 +12,10 @@ struct RootView: View { @Environment(AuthController.self) var auth var body: some View { - NavigationStack { - if auth.session == nil { - AuthView() - } else { - HomeView() - } + if auth.session == nil { + AuthView() + } else { + HomeView() } } } diff --git a/Examples/Examples/Storage/BucketDetailView.swift b/Examples/Examples/Storage/BucketDetailView.swift new file mode 100644 index 00000000..93025c8e --- /dev/null +++ b/Examples/Examples/Storage/BucketDetailView.swift @@ -0,0 +1,87 @@ +// +// BucketDetailView.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct BucketDetailView: View { + let bucket: Bucket + + @State private var fileObjects = ActionState<[FileObject], Error>.idle + @State private var presentBucketDetails = false + + var body: some View { + Group { + switch fileObjects { + case .idle: + Color.clear + case .inFlight: + ProgressView() + case let .result(.success(files)): + List { + ForEach(files) { file in + NavigationLink(file.name, value: file) + } + } + case let .result(.failure(error)): + VStack { + ErrorText(error) + Button("Retry") { + Task { await load() } + } + } + } + } + .task { await load() } + .navigationTitle("Objects") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + presentBucketDetails = true + } label: { + Label("Detail", systemImage: "info.circle") + } + } + } + .popover(isPresented: $presentBucketDetails) { + ScrollView { + Text(stringfy(bucket)) + .monospaced() + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + .navigationDestination(for: FileObject.self) { + FileObjectDetailView(api: supabase.storage.from(bucket.id), fileObject: $0) + } + } + + @MainActor + private func load() async { + fileObjects = .inFlight + fileObjects = await .result( + Result { + try await supabase.storage.from(bucket.id).list() + } + ) + } +} + +#Preview { + BucketDetailView( + bucket: Bucket( + id: UUID().uuidString, + name: "name", + owner: "owner", + isPublic: false, + createdAt: Date(), + updatedAt: Date(), + allowedMimeTypes: nil, + fileSizeLimit: nil + ) + ) +} diff --git a/Examples/Examples/Storage/BucketList.swift b/Examples/Examples/Storage/BucketList.swift new file mode 100644 index 00000000..216d3ab2 --- /dev/null +++ b/Examples/Examples/Storage/BucketList.swift @@ -0,0 +1,75 @@ +// +// BucketList.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct BucketList: View { + @State var buckets = ActionState<[Bucket], Error>.idle + + var body: some View { + Group { + switch buckets { + case .idle: + Color.clear + case .inFlight: + ProgressView() + case let .result(.success(buckets)): + List { + ForEach(buckets, id: \.self) { bucket in + NavigationLink(bucket.name, value: bucket) + } + } + .overlay { + if buckets.isEmpty { + Text("No buckets found.") + } + } + case let .result(.failure(error)): + VStack { + ErrorText(error) + Button("Retry") { + Task { + await load() + } + } + } + } + } + .task { + await load() + } + .navigationTitle("Bucket list") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add") { + Task { + do { + try await supabase.storage.createBucket("bucket-\(UUID().uuidString)") + await load() + } catch {} + } + } + } + } + } + + @MainActor + private func load() async { + do { + self.buckets = .inFlight + let buckets = try await supabase.storage.listBuckets() + self.buckets = .result(.success(buckets)) + } catch { + buckets = .result(.failure(error)) + } + } +} + +#Preview { + BucketList() +} diff --git a/Examples/Examples/Storage/FileObjectDetailView.swift b/Examples/Examples/Storage/FileObjectDetailView.swift new file mode 100644 index 00000000..1113c3bb --- /dev/null +++ b/Examples/Examples/Storage/FileObjectDetailView.swift @@ -0,0 +1,63 @@ +// +// FileObjectDetailView.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import Supabase +import SwiftUI + +struct FileObjectDetailView: View { + let api: StorageFileApi + let fileObject: FileObject + + @Environment(\.openURL) var openURL + @State var lastActionResult: (action: String, result: Any)? + + var body: some View { + List { + Section { + DisclosureGroup("Raw details") { + Text(stringfy(fileObject)) + .monospaced() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Section("Actions") { + Button("createSignedURL") { + Task { + do { + let url = try await api.createSignedURL(path: fileObject.name, expiresIn: 60) + lastActionResult = ("createSignedURL", url) + openURL(url) + } catch {} + } + } + + Button("createSignedURL (download)") { + Task { + do { + let url = try await api.createSignedURL( + path: fileObject.name, + expiresIn: 60, + download: true + ) + lastActionResult = ("createSignedURL (download)", url) + openURL(url) + } catch {} + } + } + } + + if let lastActionResult { + Section("Last action result") { + Text(lastActionResult.action) + Text(stringfy(lastActionResult.result)) + } + } + } + .navigationTitle(fileObject.name) + } +} diff --git a/Examples/Examples/Stringfy.swift b/Examples/Examples/Stringfy.swift new file mode 100644 index 00000000..35bf2808 --- /dev/null +++ b/Examples/Examples/Stringfy.swift @@ -0,0 +1,15 @@ +// +// Stringfy.swift +// Examples +// +// Created by Guilherme Souza on 21/03/24. +// + +import CustomDump +import Foundation + +func stringfy(_ value: Any) -> String { + var output = "" + customDump(value, to: &output) + return output +} diff --git a/Examples/supabase/migrations/20221223094509_init.sql b/Examples/supabase/migrations/20221223094509_init.sql index 0bb7e8ce..f6cb0b58 100644 --- a/Examples/supabase/migrations/20221223094509_init.sql +++ b/Examples/supabase/migrations/20221223094509_init.sql @@ -1,14 +1,32 @@ -create table todos ( - id uuid default uuid_generate_v4 () primary key not null, +create table todos( + id uuid default uuid_generate_v4() primary key not null, description text not null, is_complete boolean not null, created_at timestamptz default (now() at time zone 'utc'::text) not null, - owner_id uuid references auth.users (id) not null + owner_id uuid references auth.users(id) not null ); alter table todos enable row level security; create policy "Allow access to owner only" on todos as permissive for all to authenticated - using (auth.uid () = owner_id) - with check (auth.uid () = owner_id) + using (auth.uid() = owner_id) + with check (auth.uid() = owner_id); + +-- Storage +create policy "Allow authenticated users to create buckets." on storage.buckets + for insert to authenticated + with check (true); + +create policy "Allow authenticated users to list buckets." on storage.buckets + for select to authenticated + using (true); + +create policy "Allow authenticated users to upload objects." on storage.objects + for insert to authenticated + with check (true); + +create policy "Allow authenticated users to list objects." on storage.objects + for select to authenticated + using (true); + diff --git a/Package.resolved b/Package.resolved index 4d0056b8..c41b183c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 595c10ef..806a1f81 100644 --- a/Package.swift +++ b/Package.swift @@ -121,7 +121,14 @@ let package = Package( ] ), .target(name: "Storage", dependencies: ["_Helpers"]), - .testTarget(name: "StorageTests", dependencies: ["Storage"]), + .testTarget( + name: "StorageTests", + dependencies: [ + "Storage", + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), .target( name: "Supabase", dependencies: [ diff --git a/Sources/Storage/Bucket.swift b/Sources/Storage/Bucket.swift index 55c3ee76..ccdd29e4 100644 --- a/Sources/Storage/Bucket.swift +++ b/Sources/Storage/Bucket.swift @@ -1,6 +1,6 @@ import Foundation -public struct Bucket: Identifiable, Hashable, Codable { +public struct Bucket: Identifiable, Hashable, Codable, Sendable { public var id: String public var name: String public var owner: String diff --git a/Sources/Storage/BucketOptions.swift b/Sources/Storage/BucketOptions.swift index db834c3e..aac5cc52 100644 --- a/Sources/Storage/BucketOptions.swift +++ b/Sources/Storage/BucketOptions.swift @@ -1,6 +1,6 @@ import Foundation -public struct BucketOptions { +public struct BucketOptions: Sendable { public let `public`: Bool public let fileSizeLimit: Int? public let allowedMimeTypes: [String]? diff --git a/Sources/Storage/FileObject.swift b/Sources/Storage/FileObject.swift index 4aaccb29..c6e1102d 100644 --- a/Sources/Storage/FileObject.swift +++ b/Sources/Storage/FileObject.swift @@ -1,7 +1,7 @@ import _Helpers import Foundation -public struct FileObject: Identifiable, Codable { +public struct FileObject: Identifiable, Hashable, Codable, Sendable { public var name: String public var bucketId: String? public var owner: String? @@ -13,8 +13,15 @@ public struct FileObject: Identifiable, Codable { public var buckets: Bucket? public init( - name: String, bucketId: String? = nil, owner: String? = nil, id: String, updatedAt: Date, - createdAt: Date, lastAccessedAt: Date, metadata: [String: AnyJSON], buckets: Bucket? = nil + name: String, + bucketId: String? = nil, + owner: String? = nil, + id: String, + updatedAt: Date, + createdAt: Date, + lastAccessedAt: Date, + metadata: [String: AnyJSON], + buckets: Bucket? = nil ) { self.name = name self.bucketId = bucketId diff --git a/Sources/Storage/StorageError.swift b/Sources/Storage/StorageError.swift index e053595f..c7a0d0a3 100644 --- a/Sources/Storage/StorageError.swift +++ b/Sources/Storage/StorageError.swift @@ -1,11 +1,11 @@ import Foundation -public struct StorageError: Error, Decodable { - public var statusCode: String +public struct StorageError: Error, Decodable, Sendable { + public var statusCode: String? public var message: String - public var error: String + public var error: String? - public init(statusCode: String, message: String, error: String) { + public init(statusCode: String?, message: String, error: String?) { self.statusCode = statusCode self.message = message self.error = error diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index cbfd0178..5a644e01 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -24,14 +24,18 @@ public class StorageFileApi: StorageApi { super.init(configuration: configuration) } - struct UploadResponse: Decodable { + private struct UploadResponse: Decodable { let Key: String } - struct MoveResponse: Decodable { + private struct MoveResponse: Decodable { let message: String } + private struct SignedURLResponse: Decodable { + let signedURL: URL + } + func uploadOrUpdate( method: Request.Method, path: String, @@ -159,10 +163,6 @@ public class StorageFileApi: StorageApi { let transform: TransformOptions? } - struct Response: Decodable { - var signedURL: URL - } - let encoder = JSONEncoder() let response = try await execute( @@ -174,30 +174,9 @@ public class StorageFileApi: StorageApi { ) ) ) - .decoded(as: Response.self, decoder: configuration.decoder) - - guard - let signedURLComponents = URLComponents( - url: response.signedURL, - resolvingAgainstBaseURL: false - ), - var baseURLComponents = URLComponents(url: configuration.url, resolvingAgainstBaseURL: false) - else { - throw URLError(.badURL) - } + .decoded(as: SignedURLResponse.self, decoder: configuration.decoder) - baseURLComponents.path += signedURLComponents.path - baseURLComponents.queryItems = signedURLComponents.queryItems ?? [] - - if let download { - baseURLComponents.queryItems!.append(URLQueryItem(name: "download", value: download)) - } - - guard let signedURL = baseURLComponents.url else { - throw URLError(.badURL) - } - - return signedURL + return try makeSignedURL(response.signedURL, download: download) } /// Create signed url to download file without requiring permissions. This URL can be valid for a @@ -223,6 +202,77 @@ public class StorageFileApi: StorageApi { ) } + /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time. + /// - Parameters: + /// - paths: The file paths to be downloaded, including the current file names. For example + /// `["folder/image.png", "folder2/image2.png"]`. + /// - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs + /// which are valid for one minute. + /// - download: Trigger a download with the specified file name. + public func createSignedURLs( + paths: [String], + expiresIn: Int, + download: String? = nil + ) async throws -> [URL] { + struct Params: Encodable { + let expiresIn: Int + let paths: [String] + } + + let response = try await execute( + Request( + path: "/object/sign/\(bucketId)", + method: .post, + body: configuration.encoder.encode( + Params(expiresIn: expiresIn, paths: paths) + ) + ) + ) + .decoded(as: [SignedURLResponse].self, decoder: configuration.decoder) + + return try response.map { try makeSignedURL($0.signedURL, download: download) } + } + + /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time. + /// - Parameters: + /// - paths: The file paths to be downloaded, including the current file names. For example + /// `["folder/image.png", "folder2/image2.png"]`. + /// - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs + /// which are valid for one minute. + /// - download: Trigger a download with the default file name. + public func createSignedURLs( + paths: [String], + expiresIn: Int, + download: Bool + ) async throws -> [URL] { + try await createSignedURLs(paths: paths, expiresIn: expiresIn, download: download ? "" : nil) + } + + private func makeSignedURL(_ signedURL: URL, download: String?) throws -> URL { + guard + let signedURLComponents = URLComponents( + url: signedURL, + resolvingAgainstBaseURL: false + ), + var baseURLComponents = URLComponents(url: configuration.url, resolvingAgainstBaseURL: false) + else { + throw URLError(.badURL) + } + + baseURLComponents.path += signedURLComponents.path + baseURLComponents.queryItems = signedURLComponents.queryItems ?? [] + + if let download { + baseURLComponents.queryItems!.append(URLQueryItem(name: "download", value: download)) + } + + guard let signedURL = baseURLComponents.url else { + throw URLError(.badURL) + } + + return signedURL + } + /// Deletes files within the same bucket /// - Parameters: /// - paths: An array of files to be deletes, including the path and file name. For example diff --git a/Sources/Storage/StorageHTTPClient.swift b/Sources/Storage/StorageHTTPClient.swift index 286e313b..b078f701 100644 --- a/Sources/Storage/StorageHTTPClient.swift +++ b/Sources/Storage/StorageHTTPClient.swift @@ -5,8 +5,8 @@ import Foundation #endif public struct StorageHTTPSession: Sendable { - public let fetch: @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) - public let upload: + public var fetch: @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) + public var upload: @Sendable (_ request: URLRequest, _ data: Data) async throws -> (Data, URLResponse) public init( @@ -19,10 +19,10 @@ public struct StorageHTTPSession: Sendable { self.upload = upload } - public init() { + public init(session: URLSession = .shared) { self.init( - fetch: { try await URLSession.shared.data(for: $0) }, - upload: { try await URLSession.shared.upload(for: $0, from: $1) } + fetch: { try await session.data(for: $0) }, + upload: { try await session.upload(for: $0, from: $1) } ) } } diff --git a/Sources/_Helpers/Request.swift b/Sources/_Helpers/Request.swift index 618b3baf..70832c1f 100644 --- a/Sources/_Helpers/Request.swift +++ b/Sources/_Helpers/Request.swift @@ -21,7 +21,11 @@ public struct HTTPClient: Sendable { let urlRequest = try request.urlRequest(withBaseURL: baseURL) logger? .verbose( - "Request [\(id)]: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString.removingPercentEncoding ?? "")" + """ + Request [\(id)]: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString + .removingPercentEncoding ?? "") + Body: \(stringfy(urlRequest.httpBody)) + """ ) do { @@ -37,7 +41,13 @@ public struct HTTPClient: Sendable { logger? .verbose( - "Response [\(id)]: Status code: \(httpResponse.statusCode) Content-Length: \(httpResponse.expectedContentLength)" + """ + Response [\(id)]: Status code: \(httpResponse.statusCode) Content-Length: \( + httpResponse + .expectedContentLength + ) + Body: \(stringfy(data)) + """ ) return Response(data: data, response: httpResponse) @@ -46,6 +56,23 @@ public struct HTTPClient: Sendable { throw error } } + + private func stringfy(_ data: Data?) -> String { + guard let data else { + return "" + } + + do { + let object = try JSONSerialization.jsonObject(with: data, options: []) + let prettyData = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + return String(data: prettyData, encoding: .utf8) ?? "" + } catch { + return "" + } + } } @_spi(Internal) diff --git a/Tests/StorageTests/SupabaseStorageClient+Test.swift b/Tests/StorageTests/SupabaseStorageClient+Test.swift index 59ea40bf..79352763 100644 --- a/Tests/StorageTests/SupabaseStorageClient+Test.swift +++ b/Tests/StorageTests/SupabaseStorageClient+Test.swift @@ -9,7 +9,11 @@ import Foundation import Storage extension SupabaseStorageClient { - static func test(supabaseURL: String, apiKey: String) -> SupabaseStorageClient { + static func test( + supabaseURL: String, + apiKey: String, + session: StorageHTTPSession = .init() + ) -> SupabaseStorageClient { SupabaseStorageClient( configuration: StorageClientConfiguration( url: URL(string: supabaseURL)!, @@ -17,6 +21,7 @@ extension SupabaseStorageClient { "Authorization": "Bearer \(apiKey)", "Apikey": apiKey, ], + session: session, logger: nil ) ) diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index fb7a67a3..22deab51 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -1,28 +1,31 @@ +import CustomDump import Foundation -import XCTest - @testable import Storage +import XCTest +import XCTestDynamicOverlay #if canImport(FoundationNetworking) import FoundationNetworking #endif final class SupabaseStorageTests: XCTestCase { - let supabaseURL = "http://localhost:54321/storage/v1" + let supabaseURL = URL(string: "http://localhost:54321/storage/v1")! let bucketId = "tests" - lazy var storage = SupabaseStorageClient.test( - supabaseURL: supabaseURL, - apiKey: "test.api.key" + var sessionMock = StorageHTTPSession( + fetch: unimplemented("StorageHTTPSession.fetch"), + upload: unimplemented("StorageHTTPSession.upload") ) func testGetPublicURL() async throws { + let sut = makeSUT() + let path = "README.md" - let baseUrl = try storage.from(bucketId).getPublicURL(path: path) + let baseUrl = try sut.from(bucketId).getPublicURL(path: path) XCTAssertEqual(baseUrl.absoluteString, "\(supabaseURL)/object/public/\(bucketId)/\(path)") - let baseUrlWithDownload = try storage.from(bucketId).getPublicURL( + let baseUrlWithDownload = try sut.from(bucketId).getPublicURL( path: path, download: true ) @@ -31,7 +34,7 @@ final class SupabaseStorageTests: XCTestCase { "\(supabaseURL)/object/public/\(bucketId)/\(path)?download=" ) - let baseUrlWithDownloadAndFileName = try storage.from(bucketId).getPublicURL( + let baseUrlWithDownloadAndFileName = try sut.from(bucketId).getPublicURL( path: path, download: "test" ) XCTAssertEqual( @@ -39,7 +42,7 @@ final class SupabaseStorageTests: XCTestCase { "\(supabaseURL)/object/public/\(bucketId)/\(path)?download=test" ) - let baseUrlWithAllOptions = try storage.from(bucketId).getPublicURL( + let baseUrlWithAllOptions = try sut.from(bucketId).getPublicURL( path: path, download: "test", options: TransformOptions(width: 300, height: 300) ) @@ -48,4 +51,49 @@ final class SupabaseStorageTests: XCTestCase { "\(supabaseURL)/render/image/public/\(bucketId)/\(path)?download=test&width=300&height=300&resize=cover&quality=80&format=origin" ) } + + func testCreateSignedURLs() async throws { + sessionMock.fetch = { _ in + ( + """ + [ + { + "signedURL": "/sign/file1.txt?token=abc.def.ghi" + }, + { + "signedURL": "/sign/file2.txt?token=abc.def.ghi" + }, + ] + """.data(using: .utf8)!, + HTTPURLResponse( + url: self.supabaseURL, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + } + + let sut = makeSUT() + let urls = try await sut.from(bucketId).createSignedURLs( + paths: ["file1.txt", "file2.txt"], + expiresIn: 60 + ) + + XCTAssertNoDifference( + urls.map(\.absoluteString), + [ + "\(supabaseURL.absoluteString)/sign/file1.txt?token=abc.def.ghi", + "\(supabaseURL.absoluteString)/sign/file2.txt?token=abc.def.ghi", + ] + ) + } + + private func makeSUT() -> SupabaseStorageClient { + SupabaseStorageClient.test( + supabaseURL: supabaseURL.absoluteString, + apiKey: "test.api.key", + session: sessionMock + ) + } } diff --git a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved index 965ec113..4904c629 100644 --- a/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/supabase-swift.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "76135c9f4e1ac85459d5fec61b6f76ac47ab3a4c", - "version" : "3.3.1" + "revision" : "9534039303015a84837090d20fa21cae6e5eadb6", + "version" : "3.3.2" } }, {