Skip to content

Commit 576693e

Browse files
authored
feat(storage): add createSignedUploadURL and uploadToSignedURL methods (#290)
* feat(storage): add uploadToSignedURL and createSignedUploadURL methods * Fix wrong HTTP method * test: add integration test for newly added methods
1 parent 5847800 commit 576693e

File tree

12 files changed

+192
-41
lines changed

12 files changed

+192
-41
lines changed

Examples/Examples/AnyJSONView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ extension AnyJSON {
6565
}
6666
}
6767

68+
extension AnyJSONView {
69+
init(rendering value: some Codable) {
70+
self.init(value: try! AnyJSON(value))
71+
}
72+
}
73+
6874
#Preview {
6975
NavigationStack {
7076
AnyJSONView(

Examples/Examples/Storage/BucketDetailView.swift

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ struct BucketDetailView: View {
1414
@State private var fileObjects = ActionState<[FileObject], Error>.idle
1515
@State private var presentBucketDetails = false
1616

17+
@State private var lastActionResult: (action: String, result: Any)?
18+
1719
var body: some View {
1820
Group {
1921
switch fileObjects {
@@ -23,8 +25,29 @@ struct BucketDetailView: View {
2325
ProgressView()
2426
case let .result(.success(files)):
2527
List {
26-
ForEach(files) { file in
27-
NavigationLink(file.name, value: file)
28+
Section("Actions") {
29+
Button("createSignedUploadURL") {
30+
Task {
31+
do {
32+
let response = try await supabase.storage.from(bucket.id)
33+
.createSignedUploadURL(path: "\(UUID().uuidString).txt")
34+
lastActionResult = ("createSignedUploadURL", response)
35+
} catch {}
36+
}
37+
}
38+
}
39+
40+
if let lastActionResult {
41+
Section("Last action result") {
42+
Text(lastActionResult.action)
43+
Text(stringfy(lastActionResult.result))
44+
}
45+
}
46+
47+
Section("Objects") {
48+
ForEach(files) { file in
49+
NavigationLink(file.name, value: file)
50+
}
2851
}
2952
}
3053
case let .result(.failure(error)):
@@ -37,7 +60,7 @@ struct BucketDetailView: View {
3760
}
3861
}
3962
.task { await load() }
40-
.navigationTitle("Objects")
63+
.navigationTitle(bucket.name)
4164
.toolbar {
4265
ToolbarItem(placement: .primaryAction) {
4366
Button {
@@ -48,11 +71,8 @@ struct BucketDetailView: View {
4871
}
4972
}
5073
.popover(isPresented: $presentBucketDetails) {
51-
ScrollView {
52-
Text(stringfy(bucket))
53-
.monospaced()
54-
.frame(maxWidth: .infinity, alignment: .leading)
55-
.padding()
74+
List {
75+
AnyJSONView(rendering: bucket)
5676
}
5777
}
5878
.navigationDestination(for: FileObject.self) {

Examples/Examples/Storage/FileObjectDetailView.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ struct FileObjectDetailView: View {
1818
var body: some View {
1919
List {
2020
Section {
21-
DisclosureGroup("Raw details") {
22-
Text(stringfy(fileObject))
23-
.monospaced()
24-
.frame(maxWidth: .infinity, alignment: .leading)
25-
}
21+
AnyJSONView(value: try! AnyJSON(fileObject))
2622
}
2723

2824
Section("Actions") {

Sources/Storage/SignedURL.swift

Lines changed: 0 additions & 18 deletions
This file was deleted.

Sources/Storage/StorageApi.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ public class StorageApi: @unchecked Sendable {
2121

2222
@discardableResult
2323
func execute(_ request: Request) async throws -> Response {
24+
try await execute(request.urlRequest(withBaseURL: configuration.url))
25+
}
26+
27+
func execute(_ request: URLRequest) async throws -> Response {
2428
var request = request
25-
request.headers.merge(configuration.headers) { request, _ in request }
2629

27-
let response = try await http.fetch(request, baseURL: configuration.url)
30+
for (key, value) in configuration.headers {
31+
request.setValue(value, forHTTPHeaderField: key)
32+
}
2833

34+
let response = try await http.rawFetch(request)
2935
guard (200 ..< 300).contains(response.statusCode) else {
3036
let error = try configuration.decoder.decode(StorageError.self, from: response.data)
3137
throw error
@@ -37,7 +43,11 @@ public class StorageApi: @unchecked Sendable {
3743

3844
extension Request {
3945
init(
40-
path: String, method: Method, formData: FormData, options: FileOptions,
46+
path: String,
47+
method: Method,
48+
query: [URLQueryItem] = [],
49+
formData: FormData,
50+
options: FileOptions,
4151
headers: [String: String] = [:]
4252
) {
4353
var headers = headers
@@ -50,6 +60,7 @@ extension Request {
5060
self.init(
5161
path: path,
5262
method: method,
63+
query: query,
5364
headers: headers,
5465
body: formData.data
5566
)

Sources/Storage/StorageFileApi.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,92 @@ public class StorageFileApi: StorageApi {
376376
) throws -> URL {
377377
try getPublicURL(path: path, download: download ? "" : nil, options: options)
378378
}
379+
380+
/// Creates a signed upload URL.
381+
/// - Parameter path: The file path, including the current file name. For example
382+
/// `folder/image.png`.
383+
/// - Returns: A URL that can be used to upload files to the bucket without further
384+
/// authentication.
385+
///
386+
/// - Note: Signed upload URLs can be used to upload files to the bucket without further
387+
/// authentication. They are valid for 2 hours.
388+
public func createSignedUploadURL(path: String) async throws -> SignedUploadURL {
389+
struct Response: Decodable {
390+
let url: URL
391+
}
392+
393+
let response = try await execute(
394+
Request(path: "/object/upload/sign/\(bucketId)/\(path)", method: .post)
395+
)
396+
.decoded(as: Response.self, decoder: configuration.decoder)
397+
398+
let signedURL = try makeSignedURL(response.url, download: nil)
399+
400+
guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
401+
throw URLError(.badURL)
402+
}
403+
404+
guard let token = components.queryItems?.first(where: { $0.name == "token" })?.value else {
405+
throw StorageError(statusCode: nil, message: "No token returned by API", error: nil)
406+
}
407+
408+
guard let url = components.url else {
409+
throw URLError(.badURL)
410+
}
411+
412+
return SignedUploadURL(
413+
signedURL: url,
414+
path: path,
415+
token: token
416+
)
417+
}
418+
419+
/// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
420+
/// - Parameters:
421+
/// - path: The file path, including the file name. Should be of the format
422+
/// `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
423+
/// - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
424+
/// - file: The Data to be stored in the bucket.
425+
/// - options: HTTP headers, for example `cacheControl`.
426+
/// - Returns: A key pointing to stored location.
427+
@discardableResult
428+
public func uploadToSignedURL(
429+
path: String,
430+
token: String,
431+
file: Data,
432+
options: FileOptions = FileOptions()
433+
) async throws -> String {
434+
let contentType = options.contentType
435+
var headers = [
436+
"x-upsert": "\(options.upsert)",
437+
]
438+
headers["duplex"] = options.duplex
439+
440+
let fileName = fileName(fromPath: path)
441+
442+
let form = FormData()
443+
form.append(file: File(
444+
name: fileName,
445+
data: file,
446+
fileName: fileName,
447+
contentType: contentType
448+
))
449+
450+
return try await execute(
451+
Request(
452+
path: "/object/upload/sign/\(bucketId)/\(path)",
453+
method: .put,
454+
query: [
455+
URLQueryItem(name: "token", value: token),
456+
],
457+
formData: form,
458+
options: options,
459+
headers: headers
460+
)
461+
)
462+
.decoded(as: UploadResponse.self, decoder: configuration.decoder)
463+
.Key
464+
}
379465
}
380466

381467
private func fileName(fromPath path: String) -> String {

Sources/Storage/SupabaseStorage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import _Helpers
22
import Foundation
33

4+
public typealias SupabaseLogger = _Helpers.SupabaseLogger
5+
public typealias SupabaseLogMessage = _Helpers.SupabaseLogMessage
6+
47
public struct StorageClientConfiguration {
58
public let url: URL
69
public var headers: [String: String]

Sources/Storage/Types.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
public struct SearchOptions: Encodable, Sendable {
24
var prefix: String
35

@@ -67,3 +69,26 @@ public struct FileOptions: Sendable {
6769
self.duplex = duplex
6870
}
6971
}
72+
73+
public struct SignedURL: Decodable, Sendable {
74+
/// An optional error message.
75+
public var error: String?
76+
77+
/// The signed url.
78+
public var signedURL: URL
79+
80+
/// The path of the file.
81+
public var path: String
82+
83+
public init(error: String? = nil, signedURL: URL, path: String) {
84+
self.error = error
85+
self.signedURL = signedURL
86+
self.path = path
87+
}
88+
}
89+
90+
public struct SignedUploadURL: Sendable {
91+
public let signedURL: URL
92+
public let path: String
93+
public let token: String
94+
}

Sources/_Helpers/Request.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@ public struct HTTPClient: Sendable {
1717
}
1818

1919
public func fetch(_ request: Request, baseURL: URL) async throws -> Response {
20+
try await rawFetch(request.urlRequest(withBaseURL: baseURL))
21+
}
22+
23+
public func rawFetch(_ request: URLRequest) async throws -> Response {
2024
let id = UUID().uuidString
21-
let urlRequest = try request.urlRequest(withBaseURL: baseURL)
2225
logger?
2326
.verbose(
2427
"""
25-
Request [\(id)]: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString
28+
Request [\(id)]: \(request.httpMethod ?? "") \(request.url?.absoluteString
2629
.removingPercentEncoding ?? "")
27-
Body: \(stringfy(urlRequest.httpBody))
30+
Body: \(stringfy(request.httpBody))
2831
"""
2932
)
3033

3134
do {
32-
let (data, response) = try await fetchHandler(urlRequest)
35+
let (data, response) = try await fetchHandler(request)
3336

3437
guard let httpResponse = response as? HTTPURLResponse else {
3538
logger?
@@ -70,7 +73,7 @@ public struct HTTPClient: Sendable {
7073
)
7174
return String(data: prettyData, encoding: .utf8) ?? "<failed>"
7275
} catch {
73-
return "<failed>"
76+
return String(data: data, encoding: .utf8) ?? "<failed>"
7477
}
7578
}
7679
}

Tests/RealtimeTests/_PushTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import ConcurrencyExtras
99
@testable import Realtime
10+
import TestHelpers
1011
import XCTest
1112

1213
final class _PushTests: XCTestCase {

0 commit comments

Comments
 (0)