Skip to content

Commit

Permalink
feat(storage): add createSignedUploadURL and uploadToSignedURL me…
Browse files Browse the repository at this point in the history
…thods (#290)

* feat(storage): add uploadToSignedURL and createSignedUploadURL methods

* Fix wrong HTTP method

* test: add integration test for newly added methods
  • Loading branch information
grdsdev authored Mar 28, 2024
1 parent 5847800 commit 576693e
Show file tree
Hide file tree
Showing 12 changed files with 192 additions and 41 deletions.
6 changes: 6 additions & 0 deletions Examples/Examples/AnyJSONView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ extension AnyJSON {
}
}

extension AnyJSONView {
init(rendering value: some Codable) {
self.init(value: try! AnyJSON(value))
}
}

#Preview {
NavigationStack {
AnyJSONView(
Expand Down
36 changes: 28 additions & 8 deletions Examples/Examples/Storage/BucketDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct BucketDetailView: View {
@State private var fileObjects = ActionState<[FileObject], Error>.idle
@State private var presentBucketDetails = false

@State private var lastActionResult: (action: String, result: Any)?

var body: some View {
Group {
switch fileObjects {
Expand All @@ -23,8 +25,29 @@ struct BucketDetailView: View {
ProgressView()
case let .result(.success(files)):
List {
ForEach(files) { file in
NavigationLink(file.name, value: file)
Section("Actions") {
Button("createSignedUploadURL") {
Task {
do {
let response = try await supabase.storage.from(bucket.id)
.createSignedUploadURL(path: "\(UUID().uuidString).txt")
lastActionResult = ("createSignedUploadURL", response)
} catch {}
}
}
}

if let lastActionResult {
Section("Last action result") {
Text(lastActionResult.action)
Text(stringfy(lastActionResult.result))
}
}

Section("Objects") {
ForEach(files) { file in
NavigationLink(file.name, value: file)
}
}
}
case let .result(.failure(error)):
Expand All @@ -37,7 +60,7 @@ struct BucketDetailView: View {
}
}
.task { await load() }
.navigationTitle("Objects")
.navigationTitle(bucket.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Expand All @@ -48,11 +71,8 @@ struct BucketDetailView: View {
}
}
.popover(isPresented: $presentBucketDetails) {
ScrollView {
Text(stringfy(bucket))
.monospaced()
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
List {
AnyJSONView(rendering: bucket)
}
}
.navigationDestination(for: FileObject.self) {
Expand Down
6 changes: 1 addition & 5 deletions Examples/Examples/Storage/FileObjectDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ struct FileObjectDetailView: View {
var body: some View {
List {
Section {
DisclosureGroup("Raw details") {
Text(stringfy(fileObject))
.monospaced()
.frame(maxWidth: .infinity, alignment: .leading)
}
AnyJSONView(value: try! AnyJSON(fileObject))
}

Section("Actions") {
Expand Down
18 changes: 0 additions & 18 deletions Sources/Storage/SignedURL.swift

This file was deleted.

17 changes: 14 additions & 3 deletions Sources/Storage/StorageApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ public class StorageApi: @unchecked Sendable {

@discardableResult
func execute(_ request: Request) async throws -> Response {
try await execute(request.urlRequest(withBaseURL: configuration.url))
}

func execute(_ request: URLRequest) async throws -> Response {
var request = request
request.headers.merge(configuration.headers) { request, _ in request }

let response = try await http.fetch(request, baseURL: configuration.url)
for (key, value) in configuration.headers {
request.setValue(value, forHTTPHeaderField: key)
}

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

extension Request {
init(
path: String, method: Method, formData: FormData, options: FileOptions,
path: String,
method: Method,
query: [URLQueryItem] = [],
formData: FormData,
options: FileOptions,
headers: [String: String] = [:]
) {
var headers = headers
Expand All @@ -50,6 +60,7 @@ extension Request {
self.init(
path: path,
method: method,
query: query,
headers: headers,
body: formData.data
)
Expand Down
86 changes: 86 additions & 0 deletions Sources/Storage/StorageFileApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,92 @@ public class StorageFileApi: StorageApi {
) throws -> URL {
try getPublicURL(path: path, download: download ? "" : nil, options: options)
}

/// Creates a signed upload URL.
/// - Parameter path: The file path, including the current file name. For example
/// `folder/image.png`.
/// - Returns: A URL that can be used to upload files to the bucket without further
/// authentication.
///
/// - Note: Signed upload URLs can be used to upload files to the bucket without further
/// authentication. They are valid for 2 hours.
public func createSignedUploadURL(path: String) async throws -> SignedUploadURL {
struct Response: Decodable {
let url: URL
}

let response = try await execute(
Request(path: "/object/upload/sign/\(bucketId)/\(path)", method: .post)
)
.decoded(as: Response.self, decoder: configuration.decoder)

let signedURL = try makeSignedURL(response.url, download: nil)

guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
throw URLError(.badURL)
}

guard let token = components.queryItems?.first(where: { $0.name == "token" })?.value else {
throw StorageError(statusCode: nil, message: "No token returned by API", error: nil)
}

guard let url = components.url else {
throw URLError(.badURL)
}

return SignedUploadURL(
signedURL: url,
path: path,
token: token
)
}

/// 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:)``.
/// - file: 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,
file: Data,
options: FileOptions = FileOptions()
) async throws -> String {
let contentType = options.contentType
var headers = [
"x-upsert": "\(options.upsert)",
]
headers["duplex"] = options.duplex

let fileName = fileName(fromPath: path)

let form = FormData()
form.append(file: File(
name: fileName,
data: file,
fileName: fileName,
contentType: contentType
))

return try await execute(
Request(
path: "/object/upload/sign/\(bucketId)/\(path)",
method: .put,
query: [
URLQueryItem(name: "token", value: token),
],
formData: form,
options: options,
headers: headers
)
)
.decoded(as: UploadResponse.self, decoder: configuration.decoder)
.Key
}
}

private func fileName(fromPath path: String) -> String {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Storage/SupabaseStorage.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import _Helpers
import Foundation

public typealias SupabaseLogger = _Helpers.SupabaseLogger
public typealias SupabaseLogMessage = _Helpers.SupabaseLogMessage

public struct StorageClientConfiguration {
public let url: URL
public var headers: [String: String]
Expand Down
25 changes: 25 additions & 0 deletions Sources/Storage/Types.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

public struct SearchOptions: Encodable, Sendable {
var prefix: String

Expand Down Expand Up @@ -67,3 +69,26 @@ public struct FileOptions: Sendable {
self.duplex = duplex
}
}

public struct SignedURL: Decodable, Sendable {
/// An optional error message.
public var error: String?

/// The signed url.
public var signedURL: URL

/// The path of the file.
public var path: String

public init(error: String? = nil, signedURL: URL, path: String) {
self.error = error
self.signedURL = signedURL
self.path = path
}
}

public struct SignedUploadURL: Sendable {
public let signedURL: URL
public let path: String
public let token: String
}
13 changes: 8 additions & 5 deletions Sources/_Helpers/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ public struct HTTPClient: Sendable {
}

public func fetch(_ request: Request, baseURL: URL) async throws -> Response {
try await rawFetch(request.urlRequest(withBaseURL: baseURL))
}

public func rawFetch(_ request: URLRequest) async throws -> Response {
let id = UUID().uuidString
let urlRequest = try request.urlRequest(withBaseURL: baseURL)
logger?
.verbose(
"""
Request [\(id)]: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString
Request [\(id)]: \(request.httpMethod ?? "") \(request.url?.absoluteString
.removingPercentEncoding ?? "")
Body: \(stringfy(urlRequest.httpBody))
Body: \(stringfy(request.httpBody))
"""
)

do {
let (data, response) = try await fetchHandler(urlRequest)
let (data, response) = try await fetchHandler(request)

guard let httpResponse = response as? HTTPURLResponse else {
logger?
Expand Down Expand Up @@ -70,7 +73,7 @@ public struct HTTPClient: Sendable {
)
return String(data: prettyData, encoding: .utf8) ?? "<failed>"
} catch {
return "<failed>"
return String(data: data, encoding: .utf8) ?? "<failed>"
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Tests/RealtimeTests/_PushTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import ConcurrencyExtras
@testable import Realtime
import TestHelpers
import XCTest

final class _PushTests: XCTestCase {
Expand Down
14 changes: 13 additions & 1 deletion Tests/StorageTests/StorageClientIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import XCTest

final class StorageClientIntegrationTests: XCTestCase {
static var apiKey: String {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"
}

static var supabaseURL: String {
Expand Down Expand Up @@ -148,6 +148,18 @@ final class StorageClientIntegrationTests: XCTestCase {
)
}

func testCreateAndUploadToSignedUploadURL() async throws {
let path = "README-\(UUID().uuidString).md"
let url = try await storage.from(bucketId).createSignedUploadURL(path: path)
let key = try await storage.from(bucketId).uploadToSignedURL(
path: url.path,
token: url.token,
file: uploadData ?? Data()
)

XCTAssertEqual(key, "\(bucketId)/\(path)")
}

private func uploadTestData() async throws {
_ = try await storage.from(bucketId).upload(
path: "README.md", file: uploadData ?? Data(), options: FileOptions(cacheControl: "3600")
Expand Down
8 changes: 7 additions & 1 deletion Tests/StorageTests/SupabaseStorageClient+Test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,14 @@ extension SupabaseStorageClient {
"Apikey": apiKey,
],
session: session,
logger: nil
logger: ConsoleLogger()
)
)
}
}

struct ConsoleLogger: SupabaseLogger {
func log(message: SupabaseLogMessage) {
print(message.description)
}
}

0 comments on commit 576693e

Please sign in to comment.