diff --git a/Sources/Storage/StorageVectorsClient.swift b/Sources/Storage/StorageVectorsClient.swift new file mode 100644 index 000000000..080563252 --- /dev/null +++ b/Sources/Storage/StorageVectorsClient.swift @@ -0,0 +1,192 @@ +// +// StorageVectorsClient.swift +// Storage +// +// Created by Guilherme Souza on 27/07/26. +// + +public import Foundation +import HTTPTypes + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A client for managing Supabase Storage's alpha "vector buckets" feature (`storage.vectors`). +/// +/// Obtain an instance via ``SupabaseStorageClient/vectors``: +/// +/// ```swift +/// try await client.storage.vectors.createBucket("documents") +/// let buckets = try await client.storage.vectors.listBuckets().vectorBuckets +/// ``` +/// +/// - Warning: Vector buckets are a public alpha feature of Supabase Storage and this API is +/// experimental — it may change in a breaking way, or be unavailable on your project, until it +/// reaches general availability. Opt in with `@_spi(Experimental) import Supabase`. +/// +/// ## Topics +/// +/// ### Managing vector buckets +/// +/// - ``createBucket(_:)`` +/// - ``getBucket(_:)`` +/// - ``listBuckets(prefix:maxResults:nextToken:)`` +/// - ``deleteBucket(_:)`` +@_spi(Experimental) +public struct StorageVectorsClient: Sendable { + private let api: StorageApi + + init(api: StorageApi) { + self.api = api + } + + /// Creates a new vector bucket. + /// + /// ```swift + /// try await client.storage.vectors.createBucket("documents") + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to create. + /// - Throws: ``StorageError`` when the API rejects the request. + public func createBucket(_ name: String) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/CreateVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + } + + /// Retrieves the details of an existing vector bucket. + /// + /// ```swift + /// let bucket = try await client.storage.vectors.getBucket("documents") + /// print(bucket.vectorBucketName) + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to fetch. + /// - Returns: The matching ``VectorBucket``. + /// - Throws: ``StorageError`` when the API rejects the request. + public func getBucket(_ name: String) async throws -> VectorBucket { + let response: GetVectorBucketResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/GetVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + .decoded(decoder: .supabase()) + return response.vectorBucket + } + + /// Lists the vector buckets in the project, optionally filtered by name prefix. + /// + /// Results are paginated: pass the ``ListVectorBucketsResponse/nextToken`` of a previous response + /// as `nextToken` to fetch the following page. + /// + /// ```swift + /// let page = try await client.storage.vectors.listBuckets(prefix: "docs") + /// for bucket in page.vectorBuckets { + /// print(bucket.vectorBucketName) + /// } + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameters: + /// - prefix: Returns only buckets whose name starts with this prefix. Pass `nil` for all buckets. + /// - maxResults: The maximum number of buckets to return in this page. + /// - nextToken: The pagination token from a previous response. + /// - Returns: A page of buckets, plus the token for the next page when more results exist. + /// - Throws: ``StorageError`` when the API rejects the request. + public func listBuckets( + prefix: String? = nil, + maxResults: Int? = nil, + nextToken: String? = nil + ) async throws -> ListVectorBucketsResponse { + let response: ListVectorBucketsResponseBody = try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/ListVectorBuckets"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode( + VectorBucketListBody(maxResults: maxResults, nextToken: nextToken, prefix: prefix) + ) + ) + ) + .decoded(decoder: .supabase()) + return ListVectorBucketsResponse( + vectorBuckets: response.vectorBuckets, + nextToken: response.nextToken + ) + } + + /// Deletes a vector bucket. + /// + /// ```swift + /// try await client.storage.vectors.deleteBucket("documents") + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + /// + /// - Parameter name: The name of the vector bucket to delete. + /// - Throws: ``StorageError`` when the API rejects the request. + public func deleteBucket(_ name: String) async throws { + try await api.execute( + HTTPRequest( + url: api.configuration.url.appendingPathComponent("vector/DeleteVectorBucket"), + method: .post, + body: JSONEncoder.unconfiguredEncoder.encode(VectorBucketNameBody(vectorBucketName: name)) + ) + ) + } +} + +private struct VectorBucketNameBody: Encodable { + var vectorBucketName: String +} + +private struct VectorBucketListBody: Encodable { + var maxResults: Int? + var nextToken: String? + var prefix: String? +} + +private struct GetVectorBucketResponseBody: Decodable { + var vectorBucket: VectorBucket +} + +private struct ListVectorBucketsResponseBody: Decodable { + var vectorBuckets: [VectorBucket] + var nextToken: String? +} + +/// A vector bucket, as returned by ``StorageVectorsClient``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct VectorBucket: Codable, Sendable, Hashable { + /// The name of the vector bucket. + public var vectorBucketName: String + + /// UNIX timestamp (seconds) of when the bucket was created, if known. + public var creationTime: TimeInterval? +} + +/// A page of vector buckets, as returned by +/// ``StorageVectorsClient/listBuckets(prefix:maxResults:nextToken:)``. +/// +/// - Warning: Experimental. See ``StorageVectorsClient``. +@_spi(Experimental) +public struct ListVectorBucketsResponse: Sendable { + /// The buckets in this page. + public var vectorBuckets: [VectorBucket] + + /// The pagination token to pass to fetch the next page, or `nil` when there are no more results. + public var nextToken: String? +} diff --git a/Sources/Storage/SupabaseStorage.swift b/Sources/Storage/SupabaseStorage.swift index aab07265e..3831ed0ed 100644 --- a/Sources/Storage/SupabaseStorage.swift +++ b/Sources/Storage/SupabaseStorage.swift @@ -103,6 +103,7 @@ public struct StorageClientConfiguration: Sendable { /// ### Accessing buckets /// /// - ``from(_:)`` +/// - ``vectors`` /// /// ### Bucket management /// @@ -123,4 +124,17 @@ public class SupabaseStorageClient: StorageBucketApi, @unchecked Sendable { public func from(_ id: String) -> StorageFileApi { StorageFileApi(bucketId: id, configuration: configuration) } + + /// A client for managing vector buckets. + /// + /// ```swift + /// try await client.storage.vectors.createBucket("documents") + /// let buckets = try await client.storage.vectors.listBuckets().vectorBuckets + /// ``` + /// + /// - Warning: Experimental. See ``StorageVectorsClient``. + @_spi(Experimental) + public var vectors: StorageVectorsClient { + StorageVectorsClient(api: self) + } } diff --git a/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift b/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift new file mode 100644 index 000000000..479a88695 --- /dev/null +++ b/Tests/IntegrationTests/StorageVectorsClientIntegrationTests.swift @@ -0,0 +1,85 @@ +// +// StorageVectorsClientIntegrationTests.swift +// +// +// Created by Guilherme Souza on 06/08/26. +// + +import Foundation +import InlineSnapshotTesting +@_spi(Experimental) import Storage +import Testing + +@Suite(.enabled(if: ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil)) +struct StorageVectorsClientIntegrationTests { + let vectors = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_SECRET_KEY)" + ], + logger: nil + ) + ).vectors + + init() async throws { + // Clean up test-vector-bucket if it exists from a previous failed run + // to make tests idempotent + try? await vectors.deleteBucket("test-vector-bucket") + } + + @Test + func vectorBucket_CRUD() async throws { + let bucketName = "test-vector-bucket" + + var page = try await vectors.listBuckets() + #expect(!page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.createBucket(bucketName) + + let bucket = try await vectors.getBucket(bucketName) + #expect(bucket.vectorBucketName == bucketName) + + page = try await vectors.listBuckets() + #expect(page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.deleteBucket(bucketName) + + page = try await vectors.listBuckets() + #expect(!page.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + } + + @Test + func listBucketsWithPrefix() async throws { + let bucketName = "test-vector-bucket" + try await vectors.createBucket(bucketName) + + let matching = try await vectors.listBuckets(prefix: "test-vector-") + #expect(matching.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + let nonMatching = try await vectors.listBuckets(prefix: "no-such-prefix-") + #expect(!nonMatching.vectorBuckets.contains { $0.vectorBucketName == bucketName }) + + try await vectors.deleteBucket(bucketName) + } + + @Test + func getBucketWithWrongName() async { + do { + _ = try await vectors.getBucket("not-exist-bucket") + Issue.record("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "NotFoundException" + - message: "resource \\"not-exist-bucket\\" not found" + ▿ statusCode: Optional + - some: "404" + + """ + } + } + } +} diff --git a/Tests/StorageTests/StorageVectorsClientTests.swift b/Tests/StorageTests/StorageVectorsClientTests.swift new file mode 100644 index 000000000..56304f1a7 --- /dev/null +++ b/Tests/StorageTests/StorageVectorsClientTests.swift @@ -0,0 +1,138 @@ +// +// StorageVectorsClientTests.swift +// Storage +// +// Created by Guilherme Souza on 27/07/26. +// +import Foundation +import Mocker +import TestHelpers +import Testing + +@_spi(Experimental) @testable import Storage + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension StorageMockerTests { + @Suite(.mockerSerialized) + struct StorageVectorsClientTests { + let url = URL(string: "http://localhost:54321/storage/v1")! + + private func makeSUT() -> StorageVectorsClient { + Mocker.removeAll() + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + + return StorageVectorsClient( + api: StorageApi( + configuration: StorageClientConfiguration( + url: url, + headers: [:], + session: StorageHTTPSession( + fetch: { try await session.data(for: $0) }, + upload: { try await session.upload(for: $0, from: $1) } + ), + logger: nil + ) + ) + ) + } + + @Test + func createBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateVectorBucket"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await vectors.createBucket("documents") + } + + @Test + func deleteBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/DeleteVectorBucket"), + statusCode: 200, + data: [.post: Data()] + ).register() + + try await vectors.deleteBucket("documents") + } + + @Test + func getBucketDecodesVectorBucket() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/GetVectorBucket"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectorBucket":{"vectorBucketName":"documents","creationTime":1730000000}} + """.utf8 + ) + ] + ).register() + + let bucket = try await vectors.getBucket("documents") + #expect(bucket.vectorBucketName == "documents") + #expect(bucket.creationTime == 1_730_000_000) + } + + @Test + func listBucketsDecodesBucketsAndNextToken() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/ListVectorBuckets"), + statusCode: 200, + data: [ + .post: Data( + """ + {"vectorBuckets":[{"vectorBucketName":"documents"},{"vectorBucketName":"images"}],\ + "nextToken":"page-2"} + """.utf8 + ) + ] + ).register() + + let response = try await vectors.listBuckets(prefix: "doc", maxResults: 2) + #expect(response.vectorBuckets.map(\.vectorBucketName) == ["documents", "images"]) + #expect(response.nextToken == "page-2") + } + + @Test + func forbiddenThrowsStorageError() async throws { + let vectors = makeSUT() + + Mock( + url: url.appendingPathComponent("vector/CreateVectorBucket"), + statusCode: 403, + data: [ + .post: Data( + """ + {"code":"403","error":"Unauthorized","message":"new row violates row-level security",\ + "statusCode":"403"} + """.utf8 + ) + ] + ).register() + + let error = await #expect(throws: StorageError.self) { + try await vectors.createBucket("documents") + } + #expect(error?.message == "new row violates row-level security") + #expect(error?.error == "Unauthorized") + } + } +}