Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): add createSignedUploadURL and uploadToSignedURL methods #290

Merged
merged 3 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
Loading