diff --git a/Package.swift b/Package.swift index bf3cb952..b2ab78e5 100644 --- a/Package.swift +++ b/Package.swift @@ -28,12 +28,14 @@ var package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.8.1"), ], targets: [ + .target(name: "_Helpers"), .target(name: "Functions"), .testTarget(name: "FunctionsTests", dependencies: ["Functions"]), .target( name: "GoTrue", dependencies: [ - .product(name: "KeychainAccess", package: "KeychainAccess") + "_Helpers", + .product(name: "KeychainAccess", package: "KeychainAccess"), ] ), .testTarget( @@ -56,7 +58,7 @@ var package = Package( .testTarget(name: "PostgRESTIntegrationTests", dependencies: ["PostgREST"]), .target(name: "Realtime"), .testTarget(name: "RealtimeTests", dependencies: ["Realtime"]), - .target(name: "Storage"), + .target(name: "Storage", dependencies: ["_Helpers"]), .testTarget(name: "StorageTests", dependencies: ["Storage"]), .target( name: "Supabase", diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index bb82be38..71c371d9 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -1,5 +1,8 @@ import Combine import Foundation +@_spi(Internal) import _Helpers + +public typealias AnyJSON = _Helpers.AnyJSON #if canImport(FoundationNetworking) import FoundationNetworking diff --git a/Sources/GoTrue/Internal/Request.swift b/Sources/GoTrue/Internal/Request.swift deleted file mode 100644 index c1583e9e..00000000 --- a/Sources/GoTrue/Internal/Request.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -struct Request { - var path: String - var method: String - var query: [URLQueryItem] = [] - var headers: [String: String] = [:] - var body: Data? - - func urlRequest(withBaseURL baseURL: URL) throws -> URLRequest { - var url = baseURL.appendingPathComponent(path) - if !query.isEmpty { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - throw URLError(.badURL) - } - - components.queryItems = query - - if let newURL = components.url { - url = newURL - } else { - throw URLError(.badURL) - } - } - - var request = URLRequest(url: url) - request.httpMethod = method - - if body != nil, headers["Content-Type"] == nil { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - - for (name, value) in headers { - request.setValue(value, forHTTPHeaderField: name) - } - - request.httpBody = body - - return request - } -} - -struct Response { - let data: Data - let response: HTTPURLResponse - - func decoded(as _: T.Type, decoder: JSONDecoder) throws -> T { - try decoder.decode(T.self, from: data) - } -} diff --git a/Sources/GoTrue/Types.swift b/Sources/GoTrue/Types.swift index 9c567c14..e0f0de41 100644 --- a/Sources/GoTrue/Types.swift +++ b/Sources/GoTrue/Types.swift @@ -9,59 +9,6 @@ public enum AuthChangeEvent: String, Sendable { case userDeleted = "USER_DELETED" } -public enum AnyJSON: Hashable, Codable, Sendable { - case string(String) - case number(Double) - case object([String: AnyJSON]) - case array([AnyJSON]) - case bool(Bool) - case null - - public var value: Any? { - switch self { - case let .string(string): return string - case let .number(double): return double - case let .object(dictionary): return dictionary - case let .array(array): return array - case let .bool(bool): return bool - case .null: return nil - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .array(array): try container.encode(array) - case let .object(object): try container.encode(object) - case let .string(string): try container.encode(string) - case let .number(number): try container.encode(number) - case let .bool(bool): try container.encode(bool) - case .null: try container.encodeNil() - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let object = try? container.decode([String: AnyJSON].self) { - self = .object(object) - } else if let array = try? container.decode([AnyJSON].self) { - self = .array(array) - } else if let string = try? container.decode(String.self) { - self = .string(string) - } else if let bool = try? container.decode(Bool.self) { - self = .bool(bool) - } else if let number = try? container.decode(Double.self) { - self = .number(number) - } else if container.decodeNil() { - self = .null - } else { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid JSON value.") - ) - } - } -} - public struct UserCredentials: Codable, Hashable, Sendable { public var email: String? public var password: String? diff --git a/Sources/Storage/Bucket.swift b/Sources/Storage/Bucket.swift index dbce527d..55c3ee76 100644 --- a/Sources/Storage/Bucket.swift +++ b/Sources/Storage/Bucket.swift @@ -1,28 +1,38 @@ -public struct Bucket: Hashable { +import Foundation + +public struct Bucket: Identifiable, Hashable, Codable { public var id: String public var name: String public var owner: String public var isPublic: Bool - public var createdAt: String - public var updatedAt: String - - init?(from dictionary: [String: Any]) { - guard - let id = dictionary["id"] as? String, - let name = dictionary["name"] as? String, - let owner = dictionary["owner"] as? String, - let createdAt = dictionary["created_at"] as? String, - let updatedAt = dictionary["updated_at"] as? String, - let isPublic = dictionary["public"] as? Bool - else { - return nil - } + public var createdAt: Date + public var updatedAt: Date + public var allowedMimeTypes: [String]? + public var fileSizeLimit: Int? + public init( + id: String, name: String, owner: String, isPublic: Bool, createdAt: Date, updatedAt: Date, + allowedMimeTypes: [String]?, + fileSizeLimit: Int? + ) { self.id = id self.name = name self.owner = owner self.isPublic = isPublic self.createdAt = createdAt self.updatedAt = updatedAt + self.allowedMimeTypes = allowedMimeTypes + self.fileSizeLimit = fileSizeLimit + } + + enum CodingKeys: String, CodingKey { + case id + case name + case owner + case isPublic = "public" + case createdAt = "created_at" + case updatedAt = "updated_at" + case allowedMimeTypes = "allowed_mime_types" + case fileSizeLimit = "file_size_limit" } } diff --git a/Sources/Storage/Codable.swift b/Sources/Storage/Codable.swift new file mode 100644 index 00000000..40d2e6e6 --- /dev/null +++ b/Sources/Storage/Codable.swift @@ -0,0 +1,36 @@ +// +// File.swift +// +// +// Created by Guilherme Souza on 18/10/23. +// + +import Foundation + +extension JSONEncoder { + public static let defaultStorageEncoder: JSONEncoder = { + JSONEncoder() + }() +} + +extension JSONDecoder { + public static let defaultStorageDecoder: JSONDecoder = { + let decoder = JSONDecoder() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + if let date = formatter.date(from: string) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "Invalid date format: \(string)") + } + + return decoder + }() +} diff --git a/Sources/Storage/FileObject.swift b/Sources/Storage/FileObject.swift index 055d5776..33fd6e24 100644 --- a/Sources/Storage/FileObject.swift +++ b/Sources/Storage/FileObject.swift @@ -1,37 +1,41 @@ -public struct FileObject { +import Foundation +import _Helpers + +public struct FileObject: Identifiable, Codable { public var name: String public var bucketId: String? public var owner: String? public var id: String - public var updatedAt: String - public var createdAt: String - public var lastAccessedAt: String - public var metadata: [String: Any] + public var updatedAt: Date + public var createdAt: Date + public var lastAccessedAt: Date + public var metadata: [String: AnyJSON] public var buckets: Bucket? - public init?(from dictionary: [String: Any]) { - guard - let name = dictionary["name"] as? String, - let id = dictionary["id"] as? String, - let updatedAt = dictionary["updated_at"] as? String, - let createdAt = dictionary["created_at"] as? String, - let lastAccessedAt = dictionary["last_accessed_at"] as? String, - let metadata = dictionary["metadata"] as? [String: Any] - else { - return nil - } - + public init( + 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 = dictionary["bucket_id"] as? String - self.owner = dictionary["owner"] as? String + self.bucketId = bucketId + self.owner = owner self.id = id self.updatedAt = updatedAt self.createdAt = createdAt self.lastAccessedAt = lastAccessedAt self.metadata = metadata + self.buckets = buckets + } - if let buckets = dictionary["buckets"] as? [String: Any] { - self.buckets = Bucket(from: buckets) - } + enum CodingKeys: String, CodingKey { + case name + case bucketId = "bucket_id" + case owner + case id + case updatedAt = "updated_at" + case createdAt = "created_at" + case lastAccessedAt = "last_accessed_at" + case metadata + case buckets } } diff --git a/Sources/Storage/SearchOptions.swift b/Sources/Storage/SearchOptions.swift index f7cf9639..d222932e 100644 --- a/Sources/Storage/SearchOptions.swift +++ b/Sources/Storage/SearchOptions.swift @@ -1,4 +1,6 @@ -public struct SearchOptions { +public struct SearchOptions: Encodable { + public let prefix: String + /// The number of files you want to be returned. public var limit: Int? @@ -11,7 +13,11 @@ public struct SearchOptions { /// The search string to filter files by. public var search: String? - public init(limit: Int? = nil, offset: Int? = nil, sortBy: SortBy? = nil, search: String? = nil) { + public init( + prefix: String = "", limit: Int? = nil, offset: Int? = nil, sortBy: SortBy? = nil, + search: String? = nil + ) { + self.prefix = prefix self.limit = limit self.offset = offset self.sortBy = sortBy diff --git a/Sources/Storage/SignedURL.swift b/Sources/Storage/SignedURL.swift new file mode 100644 index 00000000..3ac996fb --- /dev/null +++ b/Sources/Storage/SignedURL.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct SignedURL: Decodable { + /// 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 + } +} diff --git a/Sources/Storage/SortBy.swift b/Sources/Storage/SortBy.swift index 7495c85c..e65a19b0 100644 --- a/Sources/Storage/SortBy.swift +++ b/Sources/Storage/SortBy.swift @@ -1,4 +1,4 @@ -public struct SortBy { +public struct SortBy: Encodable { public var column: String? public var order: String? diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index 775e41bf..fd1190ce 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -1,123 +1,46 @@ import Foundation +@_spi(Internal) import _Helpers #if canImport(FoundationNetworking) import FoundationNetworking #endif public class StorageApi { - var url: String - var headers: [String: String] - var session: StorageHTTPSession + var configuration: StorageClientConfiguration - init(url: String, headers: [String: String], session: StorageHTTPSession) { - self.url = url - self.headers = headers - self.session = session - } - - internal enum HTTPMethod: String { - case get = "GET" - case head = "HEAD" - case post = "POST" - case put = "PUT" - case delete = "DELETE" - case connect = "CONNECT" - case options = "OPTIONS" - case trace = "TRACE" - case patch = "PATCH" + public init(configuration: StorageClientConfiguration) { + self.configuration = configuration } @discardableResult - internal func fetch( - url: URL, - method: HTTPMethod = .get, - parameters: [String: Any]?, - headers: [String: String]? = nil - ) async throws -> Any { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - - if var headers = headers { - headers.merge(self.headers) { $1 } - request.allHTTPHeaderFields = headers - } else { - request.allHTTPHeaderFields = self.headers - } - - if let parameters = parameters { - request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) - } - - let (data, response) = try await session.fetch(request) - guard let httpResonse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if let mimeType = httpResonse.mimeType { - switch mimeType { - case "application/json": - let json = try JSONSerialization.jsonObject(with: data, options: []) - return try parse(response: json, statusCode: httpResonse.statusCode) - default: - return try parse(response: data, statusCode: httpResonse.statusCode) - } - } else { - throw StorageError(message: "failed to get response") - } - } + func execute(_ request: Request) async throws -> Response { + var request = request + request.headers.merge(configuration.headers) { request, _ in request } + let urlRequest = try request.urlRequest(withBaseURL: configuration.url) - internal func fetch( - url: URL, - method: HTTPMethod = .post, - formData: FormData, - headers: [String: String]? = nil, - fileOptions: FileOptions? = nil, - jsonSerialization: Bool = true - ) async throws -> Any { - var request = URLRequest(url: url) - request.httpMethod = method.rawValue - - if let fileOptions = fileOptions { - request.setValue(fileOptions.cacheControl, forHTTPHeaderField: "cacheControl") - } - - var allHTTPHeaderFields = self.headers - if let headers = headers { - allHTTPHeaderFields.merge(headers) { $1 } - } - - allHTTPHeaderFields.forEach { key, value in - request.setValue(value, forHTTPHeaderField: key) - } - - request.setValue(formData.contentType, forHTTPHeaderField: "Content-Type") - - let (data, response) = try await session.upload(request, formData.data) - guard let httpResonse = response as? HTTPURLResponse else { + let (data, response) = try await configuration.session.fetch(urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } - if jsonSerialization { - let json = try JSONSerialization.jsonObject(with: data, options: []) - return try parse(response: json, statusCode: httpResonse.statusCode) - } - - if let dataString = String(data: data, encoding: .utf8) { - return dataString + guard (200..<300).contains(httpResponse.statusCode) else { + let error = try configuration.decoder.decode(StorageError.self, from: data) + throw error } - throw StorageError(message: "failed to get response") + return Response(data: data, response: httpResponse) } +} - private func parse(response: Any, statusCode: Int) throws -> Any { - if statusCode == 200 || 200..<300 ~= statusCode { - return response - } else if let dict = response as? [String: Any], let message = dict["message"] as? String { - throw StorageError(statusCode: statusCode, message: message) - } else if let dict = response as? [String: Any], let error = dict["error"] as? String { - throw StorageError(statusCode: statusCode, message: error) - } else { - throw StorageError(statusCode: statusCode, message: "something went wrong") - } +extension Request { + init(path: String, method: String, formData: FormData, options: FileOptions?) { + var headers = ["Content-Type": formData.contentType] + headers["Cache-Control"] = options?.cacheControl + self.init( + path: path, + method: method, + headers: headers, + body: formData.data + ) } } diff --git a/Sources/Storage/StorageBucketApi.swift b/Sources/Storage/StorageBucketApi.swift index 46e27c8b..ef5ff42e 100644 --- a/Sources/Storage/StorageBucketApi.swift +++ b/Sources/Storage/StorageBucketApi.swift @@ -1,4 +1,5 @@ import Foundation +@_spi(Internal) import _Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -6,112 +7,67 @@ import Foundation /// Storage Bucket API public class StorageBucketApi: StorageApi { - /// StorageBucketApi initializer - /// - Parameters: - /// - url: Storage HTTP URL - /// - headers: HTTP headers. - override init(url: String, headers: [String: String], session: StorageHTTPSession) { - super.init(url: url, headers: headers, session: session) - self.headers.merge(["Content-Type": "application/json"]) { $1 } - } - /// Retrieves the details of all Storage buckets within an existing product. public func listBuckets() async throws -> [Bucket] { - guard let url = URL(string: "\(url)/bucket") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch(url: url, method: .get, parameters: nil, headers: headers) - guard let dict = response as? [[String: Any]] else { - throw StorageError(message: "failed to parse response") - } - - return dict.compactMap { Bucket(from: $0) } + try await execute(Request(path: "/bucket", method: "GET")) + .decoded(decoder: configuration.decoder) } /// Retrieves the details of an existing Storage bucket. /// - Parameters: /// - id: The unique identifier of the bucket you would like to retrieve. public func getBucket(id: String) async throws -> Bucket { - guard let url = URL(string: "\(url)/bucket/\(id)") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch(url: url, method: .get, parameters: nil, headers: headers) - guard - let dict = response as? [String: Any], - let bucket = Bucket(from: dict) - else { - throw StorageError(message: "failed to parse response") - } - - return bucket + try await execute(Request(path: "/bucket/\(id)", method: "GET")) + .decoded(decoder: configuration.decoder) } /// Creates a new Storage bucket /// - Parameters: /// - id: A unique identifier for the bucket you are creating. /// - completion: newly created bucket id - public func createBucket( - id: String, - options: BucketOptions = .init() - ) async throws -> [String: Any] { - guard let url = URL(string: "\(url)/bucket") else { - throw StorageError(message: "badURL") + public func createBucket(id: String, options: BucketOptions = .init()) async throws { + struct Parameters: Encodable { + var id: String + var name: String + var `public`: Bool + var fileSizeLimit: Int? + var allowedMimeTypes: [String]? + + enum CodingKeys: String, CodingKey { + case id + case name + case `public` = "public" + case fileSizeLimit = "file_size_limit" + case allowedMimeTypes = "allowed_mime_types" + } } - var params: [String: Any] = [ - "id": id, - "name": id, - ] - - params["public"] = options.public - params["file_size_limit"] = options.fileSizeLimit - params["allowed_mime_types"] = options.allowedMimeTypes - - let response = try await fetch( - url: url, - method: .post, - parameters: params, - headers: headers + try await execute( + Request( + path: "/bucket", + method: "POST", + body: configuration.encoder.encode( + Parameters( + id: id, name: id, public: options.public, + fileSizeLimit: options.fileSizeLimit, allowedMimeTypes: options.allowedMimeTypes + ) + ) + ) ) - - guard let dict = response as? [String: Any] else { - throw StorageError(message: "failed to parse response") - } - - return dict } /// Removes all objects inside a single bucket. /// - Parameters: /// - id: The unique identifier of the bucket you would like to empty. - @discardableResult - public func emptyBucket(id: String) async throws -> [String: Any] { - guard let url = URL(string: "\(url)/bucket/\(id)/empty") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch(url: url, method: .post, parameters: [:], headers: headers) - guard let dict = response as? [String: Any] else { - throw StorageError(message: "failed to parse response") - } - return dict + public func emptyBucket(id: String) async throws { + try await execute(Request(path: "/bucket/\(id)/empty", method: "POST")) } /// Deletes an existing bucket. A bucket can't be deleted with existing objects inside it. /// You must first `empty()` the bucket. /// - Parameters: /// - id: The unique identifier of the bucket you would like to delete. - public func deleteBucket(id: String) async throws -> [String: Any] { - guard let url = URL(string: "\(url)/bucket/\(id)") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch(url: url, method: .delete, parameters: [:], headers: headers) - guard let dict = response as? [String: Any] else { - throw StorageError(message: "failed to parse response") - } - return dict + public func deleteBucket(id: String) async throws { + try await execute(Request(path: "/bucket/\(id)", method: "DELETE")) } } diff --git a/Sources/Storage/StorageError.swift b/Sources/Storage/StorageError.swift index 3da9e188..58679597 100644 --- a/Sources/Storage/StorageError.swift +++ b/Sources/Storage/StorageError.swift @@ -1,12 +1,14 @@ import Foundation -public struct StorageError: Error { - public var statusCode: Int? - public var message: String? +public struct StorageError: Error, Decodable { + public var statusCode: String + public var message: String + public var error: String - public init(statusCode: Int? = nil, message: String? = nil) { + 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 1e09f831..fe9cd270 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -1,4 +1,5 @@ import Foundation +@_spi(Internal) import _Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -18,14 +19,9 @@ public class StorageFileApi: StorageApi { /// The bucket id to operate on. var bucketId: String - /// StorageFileApi initializer - /// - Parameters: - /// - url: Storage HTTP URL - /// - headers: HTTP headers. - /// - bucketId: The bucket id to operate on. - init(url: String, headers: [String: String], bucketId: String, session: StorageHTTPSession) { + init(bucketId: String, configuration: StorageClientConfiguration) { self.bucketId = bucketId - super.init(url: url, headers: headers, session: session) + super.init(configuration: configuration) } /// Uploads a file to an existing bucket. @@ -34,20 +30,17 @@ public class StorageFileApi: StorageApi { /// bucket must already exist before attempting to upload. /// - file: The File object to be stored in the bucket. /// - fileOptions: HTTP headers. For example `cacheControl` - public func upload(path: String, file: File, fileOptions: FileOptions?) async throws -> Any { - guard let url = URL(string: "\(url)/object/\(bucketId)/\(path)") else { - throw StorageError(message: "badURL") - } - + public func upload(path: String, file: File, fileOptions: FileOptions?) async throws { let formData = FormData() formData.append(file: file) - return try await fetch( - url: url, - method: .post, - formData: formData, - headers: headers, - fileOptions: fileOptions + try await execute( + Request( + path: "/object/\(bucketId)/\(path)", + method: "POST", + formData: formData, + options: fileOptions + ) ) } @@ -57,20 +50,17 @@ public class StorageFileApi: StorageApi { /// already exist before attempting to upload. /// - file: The file object to be stored in the bucket. /// - fileOptions: HTTP headers. For example `cacheControl` - public func update(path: String, file: File, fileOptions: FileOptions?) async throws -> Any { - guard let url = URL(string: "\(url)/object/\(bucketId)/\(path)") else { - throw StorageError(message: "badURL") - } - + public func update(path: String, file: File, fileOptions: FileOptions?) async throws { let formData = FormData() formData.append(file: file) - return try await fetch( - url: url, - method: .put, - formData: formData, - headers: headers, - fileOptions: fileOptions + try await execute( + Request( + path: "/object/\(bucketId)/\(path)", + method: "PUT", + formData: formData, + options: fileOptions + ) ) } @@ -80,22 +70,21 @@ public class StorageFileApi: StorageApi { /// `folder/image.png`. /// - toPath: The new file path, including the new file name. For example /// `folder/image-copy.png`. - public func move(fromPath: String, toPath: String) async throws -> [String: Any] { - guard let url = URL(string: "\(url)/object/move") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch( - url: url, method: .post, - parameters: ["bucketId": bucketId, "sourceKey": fromPath, "destinationKey": toPath], - headers: headers + public func move(fromPath: String, toPath: String) async throws -> [String: AnyJSON] { + try await execute( + Request( + path: "/object/move", + method: "POST", + body: configuration.encoder.encode( + [ + "bucketId": bucketId, + "sourceKey": fromPath, + "destinationKey": toPath, + ] + ) + ) ) - - guard let dict = response as? [String: Any] else { - throw StorageError(message: "failed to parse response") - } - - return dict + .decoded(decoder: configuration.decoder) } /// Create signed url to download file without requiring permissions. This URL can be valid for a @@ -105,25 +94,15 @@ public class StorageFileApi: StorageApi { /// `folder/image.png`. /// - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL /// which is valid for one minute. - public func createSignedURL(path: String, expiresIn: Int) async throws -> URL { - guard let url = URL(string: "\(url)/object/sign/\(bucketId)/\(path)") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch( - url: url, - method: .post, - parameters: ["expiresIn": expiresIn], - headers: headers + public func createSignedURL(path: String, expiresIn: Int) async throws -> SignedURL { + try await execute( + Request( + path: "/object/sign/\(bucketId)/\(path)", + method: "POST", + body: configuration.encoder.encode(["expiresIn": expiresIn]) + ) ) - guard - let dict = response as? [String: Any], - let signedURLString = dict["signedURL"] as? String, - let signedURL = URL(string: self.url.appending(signedURLString)) - else { - throw StorageError(message: "failed to parse response") - } - return signedURL + .decoded(decoder: configuration.decoder) } /// Deletes files within the same bucket @@ -131,21 +110,14 @@ public class StorageFileApi: StorageApi { /// - paths: An array of files to be deletes, including the path and file name. For example /// [`folder/image.png`]. public func remove(paths: [String]) async throws -> [FileObject] { - guard let url = URL(string: "\(url)/object/\(bucketId)") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch( - url: url, - method: .delete, - parameters: ["prefixes": paths], - headers: headers + try await execute( + Request( + path: "/object/\(bucketId)", + method: "DELETE", + body: configuration.encoder.encode(["prefixes": paths]) + ) ) - guard let array = response as? [[String: Any]] else { - throw StorageError(message: "failed to parse response") - } - - return array.compactMap { FileObject(from: $0) } + .decoded(decoder: configuration.decoder) } /// Lists all the files within a bucket. @@ -156,30 +128,13 @@ public class StorageFileApi: StorageApi { path: String? = nil, options: SearchOptions? = nil ) async throws -> [FileObject] { - guard let url = URL(string: "\(url)/object/list/\(bucketId)") else { - throw StorageError(message: "badURL") - } - - var parameters: [String: Any] = ["prefix": path ?? ""] - parameters["limit"] = options?.limit ?? DEFAULT_SEARCH_OPTIONS.limit - parameters["offset"] = options?.offset ?? DEFAULT_SEARCH_OPTIONS.offset - parameters["search"] = options?.search ?? DEFAULT_SEARCH_OPTIONS.search - - if let sortBy = options?.sortBy ?? DEFAULT_SEARCH_OPTIONS.sortBy { - parameters["sortBy"] = [ - "column": sortBy.column, - "order": sortBy.order, - ] - } - - let response = try await fetch( - url: url, method: .post, parameters: parameters, headers: headers) - - guard let array = response as? [[String: Any]] else { - throw StorageError(message: "failed to parse response") - } - - return array.compactMap { FileObject(from: $0) } + try await execute( + Request( + path: "/object/list/\(bucketId)", + method: "POST", + body: configuration.encoder.encode(options ?? DEFAULT_SEARCH_OPTIONS)) + ) + .decoded(decoder: configuration.decoder) } /// Downloads a file. @@ -188,15 +143,10 @@ public class StorageFileApi: StorageApi { /// `folder/image.png`. @discardableResult public func download(path: String) async throws -> Data { - guard let url = URL(string: "\(url)/object/\(bucketId)/\(path)") else { - throw StorageError(message: "badURL") - } - - let response = try await fetch(url: url, parameters: nil) - guard let data = response as? Data else { - throw StorageError(message: "failed to parse response") - } - return data + try await execute( + Request(path: "/object/\(bucketId)/\(path)", method: "GET") + ) + .data } /// Returns a public url for an asset. @@ -213,8 +163,9 @@ public class StorageFileApi: StorageApi { ) throws -> URL { var queryItems: [URLQueryItem] = [] - guard var components = URLComponents(string: url) else { - throw StorageError(message: "badURL") + guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true) + else { + throw URLError(.badURL) } if download { @@ -231,19 +182,9 @@ public class StorageFileApi: StorageApi { components.queryItems = !queryItems.isEmpty ? queryItems : nil guard let generatedUrl = components.url else { - throw StorageError(message: "badUrl") + throw URLError(.badURL) } return generatedUrl } - - @available(*, deprecated, renamed: "getPublicURL") - public func getPublicUrl( - path: String, - download: Bool = false, - fileName: String = "", - options: TransformOptions? = nil - ) throws -> URL { - try getPublicURL(path: path, download: download, fileName: fileName, options: options) - } } diff --git a/Sources/Storage/SupabaseStorage.swift b/Sources/Storage/SupabaseStorage.swift index c9947e22..3d67a419 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -1,18 +1,32 @@ -public class SupabaseStorageClient: StorageBucketApi { - /// Storage Client initializer - /// - Parameters: - /// - url: Storage HTTP URL - /// - headers: HTTP headers. - override public init( - url: String, headers: [String: String], session: StorageHTTPSession = .init() +import Foundation + +public struct StorageClientConfiguration { + public let url: URL + public var headers: [String: String] + public let encoder: JSONEncoder + public let decoder: JSONDecoder + public let session: StorageHTTPSession + + public init( + url: URL, + headers: [String: String], + encoder: JSONEncoder = .defaultStorageEncoder, + decoder: JSONDecoder = .defaultStorageDecoder, + session: StorageHTTPSession = .init() ) { - super.init(url: url, headers: headers, session: session) + self.url = url + self.headers = headers + self.encoder = encoder + self.decoder = decoder + self.session = session } +} +public class SupabaseStorageClient: StorageBucketApi { /// Perform file operation in a bucket. /// - Parameter id: The bucket id to operate on. /// - Returns: StorageFileApi object public func from(id: String) -> StorageFileApi { - StorageFileApi(url: url, headers: headers, bucketId: id, session: session) + StorageFileApi(bucketId: id, configuration: configuration) } } diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index 2bfd3aca..57f3283d 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -30,9 +30,11 @@ public class SupabaseClient { /// Supabase Storage allows you to manage user-generated content, such as photos or videos. public var storage: SupabaseStorageClient { SupabaseStorageClient( - url: storageURL.absoluteString, - headers: defaultHeaders, - session: StorageHTTPSession(fetch: fetch, upload: upload) + configuration: StorageClientConfiguration( + url: storageURL, + headers: defaultHeaders, + session: StorageHTTPSession(fetch: fetch, upload: upload) + ) ) } diff --git a/Sources/_Helpers/AnyJSON.swift b/Sources/_Helpers/AnyJSON.swift new file mode 100644 index 00000000..5f4a9b13 --- /dev/null +++ b/Sources/_Helpers/AnyJSON.swift @@ -0,0 +1,54 @@ +import Foundation + +public enum AnyJSON: Hashable, Codable, Sendable { + case string(String) + case number(Double) + case object([String: AnyJSON]) + case array([AnyJSON]) + case bool(Bool) + case null + + public var value: Any? { + switch self { + case let .string(string): return string + case let .number(double): return double + case let .object(dictionary): return dictionary + case let .array(array): return array + case let .bool(bool): return bool + case .null: return nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .array(array): try container.encode(array) + case let .object(object): try container.encode(object) + case let .string(string): try container.encode(string) + case let .number(number): try container.encode(number) + case let .bool(bool): try container.encode(bool) + case .null: try container.encodeNil() + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let object = try? container.decode([String: AnyJSON].self) { + self = .object(object) + } else if let array = try? container.decode([AnyJSON].self) { + self = .array(array) + } else if let string = try? container.decode(String.self) { + self = .string(string) + } else if let bool = try? container.decode(Bool.self) { + self = .bool(bool) + } else if let number = try? container.decode(Double.self) { + self = .number(number) + } else if container.decodeNil() { + self = .null + } else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Invalid JSON value.") + ) + } + } +} diff --git a/Sources/_Helpers/Request.swift b/Sources/_Helpers/Request.swift new file mode 100644 index 00000000..2e5e47f2 --- /dev/null +++ b/Sources/_Helpers/Request.swift @@ -0,0 +1,68 @@ +import Foundation + +@_spi(Internal) +public struct Request { + public var path: String + public var method: String + public var query: [URLQueryItem] + public var headers: [String: String] + public var body: Data? + + public init( + path: String, method: String, query: [URLQueryItem] = [], headers: [String: String] = [:], + body: Data? = nil + ) { + self.path = path + self.method = method + self.query = query + self.headers = headers + self.body = body + } + + public func urlRequest(withBaseURL baseURL: URL) throws -> URLRequest { + var url = baseURL.appendingPathComponent(path) + if !query.isEmpty { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw URLError(.badURL) + } + + components.queryItems = query + + if let newURL = components.url { + url = newURL + } else { + throw URLError(.badURL) + } + } + + var request = URLRequest(url: url) + request.httpMethod = method + + if body != nil, headers["Content-Type"] == nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + + request.httpBody = body + + return request + } +} + +@_spi(Internal) +public struct Response { + public let data: Data + public let response: HTTPURLResponse + + public init(data: Data, response: HTTPURLResponse) { + self.data = data + self.response = response + } + + public func decoded(as _: T.Type = T.self, decoder: JSONDecoder) throws -> T { + try decoder.decode(T.self, from: data) + } +} diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index 987e2c58..14bea8e7 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -19,11 +19,13 @@ final class SupabaseStorageTests: XCTestCase { let bucket = "public" let storage = SupabaseStorageClient( - url: supabaseURL, - headers: [ - "Authorization": "Bearer \(apiKey)", - "apikey": apiKey, - ] + configuration: StorageClientConfiguration( + url: URL(string: supabaseURL)!, + headers: [ + "Authorization": "Bearer \(apiKey)", + "apikey": apiKey, + ] + ) ) let uploadData = try! Data( diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 00000000..773c7c3e --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,3 @@ +# Supabase +.branches +.temp diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..bf1783a8 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,134 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "supabase-swift" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = ["public", "storage", "graphql_public"] +# Extra schemas to add to the search_path of every request. public is always included. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialise the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv6) +# ip_version = "IPv6" + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://localhost" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://localhost:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://localhost:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = true +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false + +# Use pre-defined map of phone number to OTP for testing. +[auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" + +[analytics] +enabled = false +port = 54327 +vector_port = 54328 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" diff --git a/supabase/migrations/20231018123414_storage_init.sql b/supabase/migrations/20231018123414_storage_init.sql new file mode 100644 index 00000000..d69896be --- /dev/null +++ b/supabase/migrations/20231018123414_storage_init.sql @@ -0,0 +1,9 @@ +create policy "Allow all" on "storage"."objects" as PERMISSIVE + for all to public + using (true) + with check (true); + +create policy "Allow all" on "storage"."buckets" as PERMISSIVE + for all to public + using (true) + with check (true); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 00000000..e69de29b