diff --git a/.gitignore b/.gitignore index dcdfc9f..8d739bc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ site/ # AI-assisted planning docs (agentic skill framework artefacts — not project documentation) docs/superpowers/ + +# Generated SDK output is a build product (regenerated from the committed normalized specs). +# Not committed for now — Plan B will settle the committed-vs-buildtime model for the lean templates. +codegen/generated/ + +# Stray @openapitools/openapi-generator-cli config (we use the standalone binary, version pinned in codegen.yaml) +openapitools.json diff --git a/README.md b/README.md index b25c9b1..6099686 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,50 @@ scripts/ # TypeScript validator + site generator (scripts/capability-matr The full schema lives in `schema/capability-matrix.schema.json`. +## Code generation contract + +SDKs generate their transport, models, and error types from upstream OpenAPI specs. This repo is the contract: + +- A feature may declare an optional `binding` to the OpenAPI operation it maps to: + + ```yaml + - id: storage.objects.upload + name: Upload Object + description: Upload a file to a bucket. + group: objects + binding: + spec: storage # must match a spec id in codegen.yaml + operationId: uploadObject + ``` + +- `codegen.yaml` (repo root) pins the generator engine, the spec sources, and the per-language template packs: + + ```yaml + engine: + tool: openapi-generator + version: 7.10.0 + specs: + storage: + source: https://.../storage/openapi.yaml + version: + languages: + swift: + generator: swift5 + templates: templates/swift + ``` + +- `conformance/**/*.yaml` holds language-agnostic test vectors each SDK runs: + + ```yaml + feature: storage.objects.upload + cases: + - name: uploads a small file + input: { path: a.txt, body: hi } + expected: { status: 200 } + ``` + +`npm run validate` enforces that bindings reference declared specs, that `codegen.yaml` matches its schema, and that conformance vectors are well-formed and reference real features. The full schemas live in `schema/codegen.schema.json` and `schema/conformance.schema.json`. + ## SDK compliance SDK compliance is **declared in each SDK repo**, not here. To report which features your SDK implements, add a `sdk-compliance.yaml` file to the root of your SDK repo: diff --git a/capabilities/storage.yaml b/capabilities/storage.yaml index 1a71ab1..a8b9825 100644 --- a/capabilities/storage.yaml +++ b/capabilities/storage.yaml @@ -22,6 +22,9 @@ features: name: Create File Bucket description: Create a new file storage bucket. group: file_buckets + binding: + spec: storage + operationId: createBucket - id: storage.file_buckets.create_signed_upload_url name: Create Signed Upload URL description: Create a signed URL that allows an unauthenticated client to upload a file to the bucket. @@ -38,6 +41,9 @@ features: name: Delete File Bucket description: Delete an existing file bucket. The bucket must be empty before deletion. group: file_buckets + binding: + spec: storage + operationId: deleteBucket - id: storage.file_buckets.download name: Download File description: Download a file from a private bucket. @@ -58,6 +64,9 @@ features: name: Get Bucket description: Retrieve the details of a specific storage bucket. group: file_buckets + binding: + spec: storage + operationId: getBucket - id: storage.file_buckets.get_public_url name: Get Public URL description: Get the public URL for a file in a public bucket. @@ -66,6 +75,9 @@ features: name: List File Buckets description: List all file storage buckets in the project. group: file_buckets + binding: + spec: storage + operationId: listBuckets - id: storage.file_buckets.list_files name: List Files description: List files and folders within a path of the bucket. @@ -94,6 +106,9 @@ features: name: Upload File description: Upload a file to an existing bucket. group: file_buckets + binding: + spec: storage + operationId: uploadObject - id: storage.file_buckets.upload_with_signed_url name: Upload with Signed URL description: Upload a file using a pre-signed upload URL, without requiring standard authentication. diff --git a/codegen.yaml b/codegen.yaml new file mode 100644 index 0000000..90e0ce0 --- /dev/null +++ b/codegen.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=./schema/codegen.schema.json +engine: + tool: openapi-generator + version: "7.23.0" + +specs: + storage: + source: codegen/specs/storage.normalized.json + version: "gh-pages@53e6a743d5b02e7e7e7b7549f7490517773be016" + +languages: + swift: + generator: swift6 + generatorProperties: + projectName: SupabaseStorage + responseAs: AsyncAwait + library: urlsession + useSPMFileStructure: "true" + nonPublicApi: "true" + +targets: + - spec: storage + language: swift + output: codegen/generated/swift-storage diff --git a/codegen/normalize/storage.json b/codegen/normalize/storage.json new file mode 100644 index 0000000..f57a74c --- /dev/null +++ b/codegen/normalize/storage.json @@ -0,0 +1,17 @@ +{ + "schemaRenames": { + "def-0": "AuthHeader", + "def-1": "ErrorBody" + }, + "operationIdOverrides": { + "POST /object/{bucketName}/{objectPath}": "uploadObject", + "GET /bucket/": "listBuckets", + "POST /bucket/": "createBucket", + "DELETE /bucket/{bucketId}": "deleteBucket", + "GET /bucket/{bucketId}": "getBucket" + }, + "requestBodyInjections": { + "POST /object/{bucketName}/{objectPath}": { "required": true, "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } }, + "PUT /object/{bucketName}/{objectPath}": { "required": true, "content": { "application/octet-stream": { "schema": { "type": "string", "format": "binary" } } } } + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/.gitignore b/codegen/runtime/swift/SupabaseRuntime/.gitignore new file mode 100644 index 0000000..e369790 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/.gitignore @@ -0,0 +1,3 @@ +.build/ +*.o +*.d diff --git a/codegen/runtime/swift/SupabaseRuntime/Package.swift b/codegen/runtime/swift/SupabaseRuntime/Package.swift new file mode 100644 index 0000000..3ed471c --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SupabaseRuntime", + platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)], + products: [.library(name: "SupabaseRuntime", targets: ["SupabaseRuntime"])], + targets: [ + .target( + name: "SupabaseRuntime", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "SupabaseRuntimeTests", + dependencies: ["SupabaseRuntime"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/AuthProvider.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/AuthProvider.swift new file mode 100644 index 0000000..8076fb2 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/AuthProvider.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Supplies auth headers per request; async so token refresh fits. +public struct AuthProvider: Sendable { + private let provider: @Sendable () async throws -> [String: String] + public init(_ provider: @escaping @Sendable () async throws -> [String: String]) { self.provider = provider } + public func headers() async throws -> [String: String] { try await provider() } + + /// No auth headers. + public static let none = AuthProvider { [:] } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/ClientConfiguration.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/ClientConfiguration.swift new file mode 100644 index 0000000..37d1186 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/ClientConfiguration.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum SessionKind: Sendable, Equatable { + case foreground + case background(identifier: String) +} + +public struct ClientConfiguration: Sendable { + public var baseURL: URL + public var defaultHeaders: [String: String] + public var auth: AuthProvider + public var encoder: JSONEncoder + public var decoder: JSONDecoder + public var sessionKind: SessionKind + /// Maps a non-2xx (body, head) to a typed error; returns nil to fall back to `TransportError.http`. + public var errorMapper: @Sendable (Data, HTTPResponseHead) -> (any Error)? + + public init( + baseURL: URL, + defaultHeaders: [String: String] = [:], + auth: AuthProvider = .none, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder(), + sessionKind: SessionKind = .foreground, + errorMapper: @escaping @Sendable (Data, HTTPResponseHead) -> (any Error)? = { _, _ in nil } + ) { + self.baseURL = baseURL + self.defaultHeaders = defaultHeaders + self.auth = auth + self.encoder = encoder + self.decoder = decoder + self.sessionKind = sessionKind + self.errorMapper = errorMapper + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/HTTPRequest.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/HTTPRequest.swift new file mode 100644 index 0000000..c527224 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/HTTPRequest.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" + case patch = "PATCH" + case head = "HEAD" +} + +public struct HTTPRequest: Sendable { + public var method: HTTPMethod + public var path: String + public var query: [URLQueryItem] + public var headers: [String: String] + + public init(method: HTTPMethod, path: String, query: [URLQueryItem] = [], headers: [String: String] = [:]) { + self.method = method + self.path = path + self.query = query + self.headers = headers + } + + public init(method: HTTPMethod, path: RequestPath, query: [URLQueryItem] = [], headers: [String: String] = [:]) { + self.init(method: method, path: path.value, query: query, headers: headers) + } +} + +public struct HTTPResponseHead: Sendable { + public let status: Int + public let headers: [String: String] + public init(status: Int, headers: [String: String]) { + self.status = status + self.headers = headers + } +} + +public enum UploadSource: Sendable { + case file(URL) + case data(Data) +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/MockTransport.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/MockTransport.swift new file mode 100644 index 0000000..b18c9d6 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/MockTransport.swift @@ -0,0 +1,71 @@ +import Foundation + +/// In-memory `Transport` for tests. The `responder` receives the request and the +/// encoded request body (nil for body-less sends/downloads), so tests can assert both. +public struct MockTransport: Transport { + public typealias Responder = @Sendable (HTTPRequest, Data?) async throws -> (Int, Data) + public let responder: Responder + public let encoder: JSONEncoder + public let decoder: JSONDecoder + + public init(encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), _ responder: @escaping Responder) { + self.responder = responder + self.encoder = encoder + self.decoder = decoder + } + + public func send(_ request: HTTPRequest) async throws -> R { + let (_, data) = try await responder(request, nil) + return try decoder.decode(R.self, from: data) + } + + public func send(_ request: HTTPRequest, body: B) async throws -> R { + let bodyData = try encoder.encode(body) + let (_, data) = try await responder(request, bodyData) + return try decoder.decode(R.self, from: data) + } + + public func send(_ request: HTTPRequest) async throws { + _ = try await responder(request, nil) + } + + public func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + let responder = self.responder + let decoder = self.decoder + let (stream, cont) = AsyncStream.makeStream() + cont.finish() + return TransferTask( + progress: stream, + value: { + let bodyData: Data? + switch source { + case .data(let d): bodyData = d + case .file(let url): bodyData = try Data(contentsOf: url) + } + let (_, data) = try await responder(request, bodyData) + return try decoder.decode(R.self, from: data) + }, + cancel: {} + ) + } + + public func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask { + let responder = self.responder + let (stream, cont) = AsyncStream.makeStream() + cont.finish() + return TransferTask( + progress: stream, + value: { let (_, data) = try await responder(request, nil); try data.write(to: destination) }, + cancel: {} + ) + } + + public func stream(_ request: HTTPRequest) async throws -> ResponseStream { + let (status, data) = try await responder(request, nil) + let body = AsyncThrowingStream, any Error> { cont in + cont.yield(ArraySlice(data)) + cont.finish() + } + return ResponseStream(head: HTTPResponseHead(status: status, headers: [:]), body: body) + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/RequestPath.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/RequestPath.swift new file mode 100644 index 0000000..1b50b56 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/RequestPath.swift @@ -0,0 +1,25 @@ +import Foundation + +private let pathParamAllowedCharacters: CharacterSet = { + var cs = CharacterSet.urlPathAllowed + cs.remove("/") + return cs +}() + +public struct RequestPath: Sendable, ExpressibleByStringInterpolation { + public let value: String + + public init(stringLiteral value: String) { self.value = value } + public init(stringInterpolation: StringInterpolation) { self.value = stringInterpolation.text } + public init(_ path: RequestPath) { self.value = path.value } + + public struct StringInterpolation: StringInterpolationProtocol { + var text = "" + public init(literalCapacity: Int, interpolationCount: Int) { text.reserveCapacity(literalCapacity) } + public mutating func appendLiteral(_ literal: String) { text += literal } + /// Percent-encodes a raw path-segment value (slashes become %2F). Pass the raw, unencoded value — do not pre-encode. + public mutating func appendInterpolation(param value: String) { + text += value.addingPercentEncoding(withAllowedCharacters: pathParamAllowedCharacters) ?? value + } + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Streaming.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Streaming.swift new file mode 100644 index 0000000..f8ef29f --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Streaming.swift @@ -0,0 +1,42 @@ +public struct TransferProgress: Sendable { + public let completed: Int64 + public let total: Int64? + public init(completed: Int64, total: Int64?) { + self.completed = completed + self.total = total + } + public var fraction: Double? { + guard let total, total > 0 else { return nil } + return Double(completed) / Double(total) + } +} + +/// Handle for an in-flight upload/download: live progress + the awaitable result. +public struct TransferTask: Sendable { + public let progress: AsyncStream + private let _value: @Sendable () async throws -> Value + private let _cancel: @Sendable () -> Void + + public init( + progress: AsyncStream, + value: @escaping @Sendable () async throws -> Value, + cancel: @escaping @Sendable () -> Void + ) { + self.progress = progress + self._value = value + self._cancel = cancel + } + + public func value() async throws -> Value { try await _value() } + public func cancel() { _cancel() } +} + +/// A streamed response body (event streams / SSE / incremental reads). +public struct ResponseStream: Sendable { + public let head: HTTPResponseHead + public let body: AsyncThrowingStream, any Error> + public init(head: HTTPResponseHead, body: AsyncThrowingStream, any Error>) { + self.head = head + self.body = body + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TaskProgressDelegate.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TaskProgressDelegate.swift new file mode 100644 index 0000000..a0b78c2 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TaskProgressDelegate.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Per-call URLSession task delegate that forwards byte-progress into an AsyncStream. +/// `@unchecked Sendable`: it holds only a `Sendable` continuation; URLSession invokes +/// these callbacks serially per task. (NSObject delegates can't be cleanly Sendable.) +final class TaskProgressDelegate: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate, @unchecked Sendable { + let continuation: AsyncStream.Continuation + init(continuation: AsyncStream.Continuation) { self.continuation = continuation } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + let total = totalBytesExpectedToSend > 0 ? totalBytesExpectedToSend : nil + continuation.yield(TransferProgress(completed: totalBytesSent, total: total)) + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let total = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : nil + continuation.yield(TransferProgress(completed: totalBytesWritten, total: total)) + } + + // Required by URLSessionDownloadDelegate; the per-call async API handles the temp-file + // lifetime internally so we have no action to take here. + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {} +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Transport.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Transport.swift new file mode 100644 index 0000000..f68dd8b --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Transport.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol Transport: Sendable { + func send(_ request: HTTPRequest) async throws -> R + func send(_ request: HTTPRequest, body: B) async throws -> R + func send(_ request: HTTPRequest) async throws + func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask + func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask + func stream(_ request: HTTPRequest) async throws -> ResponseStream +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TransportError.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TransportError.swift new file mode 100644 index 0000000..6a86320 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TransportError.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum TransportError: Error, Sendable { + case http(status: Int, body: Data, head: HTTPResponseHead) + case transport(any Error) + case decoding(any Error) + case cancelled + case backgroundRequiresFile +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift new file mode 100644 index 0000000..f35c569 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift @@ -0,0 +1,170 @@ +import Foundation + +public actor URLSessionTransport: Transport { + let configuration: ClientConfiguration + nonisolated let urlSession: URLSession // URLSession is Sendable; readable from nonisolated upload/download + + public init(configuration: ClientConfiguration, urlSession: URLSession? = nil) { + self.configuration = configuration + if let urlSession { + self.urlSession = urlSession + } else { + let cfg: URLSessionConfiguration + switch configuration.sessionKind { + case .foreground: cfg = .default + case .background(let id): cfg = .background(withIdentifier: id) + } + // No session-level delegate — progress is handled by per-call TaskProgressDelegate. + self.urlSession = URLSession(configuration: cfg) + } + } + + func makeURLRequest(_ request: HTTPRequest) async throws -> URLRequest { + var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false)! + if components.path.hasSuffix("/") { components.path.removeLast() } + components.path += request.path + if !request.query.isEmpty { components.queryItems = request.query } + var urlRequest = URLRequest(url: components.url!) + urlRequest.httpMethod = request.method.rawValue + for (k, v) in configuration.defaultHeaders { urlRequest.setValue(v, forHTTPHeaderField: k) } + for (k, v) in try await configuration.auth.headers() { urlRequest.setValue(v, forHTTPHeaderField: k) } + for (k, v) in request.headers { urlRequest.setValue(v, forHTTPHeaderField: k) } + return urlRequest + } + + nonisolated func head(from response: URLResponse) -> HTTPResponseHead { + let http = response as? HTTPURLResponse + let headers = (http?.allHeaderFields as? [String: String]) ?? [:] + return HTTPResponseHead(status: http?.statusCode ?? 0, headers: headers) + } + + func validate(_ data: Data, _ response: URLResponse) throws -> Data { + let responseHead = head(from: response) + guard (200..<300).contains(responseHead.status) else { + if let mapped = configuration.errorMapper(data, responseHead) { throw mapped } + throw TransportError.http(status: responseHead.status, body: data, head: responseHead) + } + return data + } + + /// Nonisolated variant for use from `upload`/`download` — `configuration` is a `let` Sendable value, + /// so it is accessible from nonisolated contexts. + nonisolated func validateNonisolated(_ data: Data, _ response: URLResponse) throws -> Data { + let responseHead = head(from: response) + guard (200..<300).contains(responseHead.status) else { + if let mapped = configuration.errorMapper(data, responseHead) { throw mapped } + throw TransportError.http(status: responseHead.status, body: data, head: responseHead) + } + return data + } + + public func send(_ request: HTTPRequest) async throws -> R { + let urlRequest = try await makeURLRequest(request) + let (data, response) = try await urlSession.data(for: urlRequest) + let body = try validate(data, response) + do { return try configuration.decoder.decode(R.self, from: body) } + catch { throw TransportError.decoding(error) } + } + + public func send(_ request: HTTPRequest, body: B) async throws -> R { + var urlRequest = try await makeURLRequest(request) + urlRequest.httpBody = try configuration.encoder.encode(body) + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let (data, response) = try await urlSession.data(for: urlRequest) + let validated = try validate(data, response) + do { return try configuration.decoder.decode(R.self, from: validated) } + catch { throw TransportError.decoding(error) } + } + + public func send(_ request: HTTPRequest) async throws { + let urlRequest = try await makeURLRequest(request) + let (data, response) = try await urlSession.data(for: urlRequest) + _ = try validate(data, response) + } + + // MARK: - Streaming upload/download (Task 6) + + public nonisolated func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + let isBackground: Bool = { if case .background = configuration.sessionKind { return true } else { return false } }() + let (stream, cont) = AsyncStream.makeStream() + let task = Task { [self] () -> R in + defer { cont.finish() } + if isBackground, case .data = source { throw TransportError.backgroundRequiresFile } + let urlRequest = try await makeURLRequest(request) + let progressDelegate = TaskProgressDelegate(continuation: cont) + let data: Data + let response: URLResponse + switch source { + case .file(let url): (data, response) = try await urlSession.upload(for: urlRequest, fromFile: url, delegate: progressDelegate) + case .data(let d): (data, response) = try await urlSession.upload(for: urlRequest, from: d, delegate: progressDelegate) + } + let validated = try validateNonisolated(data, response) + do { return try configuration.decoder.decode(R.self, from: validated) } + catch { throw TransportError.decoding(error) } + } + return TransferTask(progress: stream, value: { try await task.value }, cancel: { task.cancel() }) + } + + public nonisolated func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask { + let (stream, cont) = AsyncStream.makeStream() + let task = Task { [self] () -> Void in + defer { cont.finish() } + let urlRequest = try await makeURLRequest(request) + let progressDelegate = TaskProgressDelegate(continuation: cont) + let (tempURL, response) = try await urlSession.download(for: urlRequest, delegate: progressDelegate) + let responseHead = head(from: response) + guard (200..<300).contains(responseHead.status) else { + let body = (try? Data(contentsOf: tempURL)) ?? Data() + if let mapped = configuration.errorMapper(body, responseHead) { throw mapped } + throw TransportError.http(status: responseHead.status, body: body, head: responseHead) + } + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: tempURL, to: destination) + } + return TransferTask(progress: stream, value: { try await task.value }, cancel: { task.cancel() }) + } + public func stream(_ request: HTTPRequest) async throws -> ResponseStream { + let urlRequest = try await makeURLRequest(request) + let (bytes, response) = try await urlSession.bytes(for: urlRequest) + let responseHead = head(from: response) + guard (200..<300).contains(responseHead.status) else { + var collected = Data() + for try await byte in bytes { collected.append(byte) } + if let mapped = configuration.errorMapper(collected, responseHead) { throw mapped } + throw TransportError.http(status: responseHead.status, body: collected, head: responseHead) + } + let body = AsyncThrowingStream, any Error> { continuation in + let task = Task { + do { + var chunk = [UInt8]() + chunk.reserveCapacity(4096) + for try await byte in bytes { + chunk.append(byte) + if chunk.count >= 4096 { continuation.yield(ArraySlice(chunk)); chunk.removeAll(keepingCapacity: true) } + } + if !chunk.isEmpty { continuation.yield(ArraySlice(chunk)) } + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + continuation.onTermination = { _ in task.cancel() } + } + return ResponseStream(head: responseHead, body: body) + } + + // MARK: - Background relaunch hook (Task 8) + + private var backgroundCompletions: [String: @Sendable () -> Void] = [:] + + /// Call from the app's `handleEventsForBackgroundURLSession` / SwiftUI `backgroundTask`. + public func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { + backgroundCompletions[identifier] = completionHandler + } + + /// Returns and clears the stored completion for `identifier` (call once the session finishes delivering events). + public func consumeBackgroundCompletion(identifier: String) -> (@Sendable () -> Void)? { + defer { backgroundCompletions[identifier] = nil } + return backgroundCompletions[identifier] + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/BackgroundSessionTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/BackgroundSessionTests.swift new file mode 100644 index 0000000..ae1765f --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/BackgroundSessionTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct R: Codable, Sendable { let ok: Bool } + +@Suite struct BackgroundSessionTests { + // Inject a stub session so no real background URLSession is created; the guard keys off sessionKind. + func backgroundTransport(id: String) -> URLSessionTransport { + let (session, _) = StubURLProtocol.makeSession(stub: .init()) + let config = ClientConfiguration(baseURL: URL(string: "https://x.test/v1")!, sessionKind: .background(identifier: id)) + return URLSessionTransport(configuration: config, urlSession: session) + } + + @Test func backgroundUploadFromDataThrowsRequiresFile() async { + let transport = backgroundTransport(id: "test.bg") + let task: TransferTask = transport.upload(HTTPRequest(method: .post, path: "/object/b/k"), from: .data(Data([1]))) + await #expect(throws: TransportError.self) { _ = try await task.value() } + } + + @Test func handleBackgroundEventsStoresAndConsumesCompletion() async { + let transport = backgroundTransport(id: "test.bg2") + let box = CompletionBox() + await transport.handleBackgroundEvents(identifier: "test.bg2") { box.fire() } + let stored = await transport.consumeBackgroundCompletion(identifier: "test.bg2") + stored?() + #expect(box.fired == true) + // consumed once: second consume is nil + let again = await transport.consumeBackgroundCompletion(identifier: "test.bg2") + #expect(again == nil) + } +} + +private final class CompletionBox: @unchecked Sendable { + private let lock = NSLock(); private var value = false + func fire() { lock.withLock { value = true } } + var fired: Bool { lock.withLock { value } } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/ClientConfigurationTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/ClientConfigurationTests.swift new file mode 100644 index 0000000..27c382b --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/ClientConfigurationTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +@Suite struct ClientConfigurationTests { + @Test func authProviderSuppliesHeaders() async throws { + let auth = AuthProvider { ["Authorization": "Bearer t", "apikey": "k"] } + let headers = try await auth.headers() + #expect(headers["Authorization"] == "Bearer t") + #expect(headers["apikey"] == "k") + } + + @Test func noneAuthProviderIsEmpty() async throws { + let headers = try await AuthProvider.none.headers() + #expect(headers.isEmpty) + } + + @Test func configHoldsBaseURLAndDefaults() { + let config = ClientConfiguration(baseURL: URL(string: "https://x.supabase.co/storage/v1")!) + #expect(config.baseURL.absoluteString == "https://x.supabase.co/storage/v1") + #expect(config.sessionKind == .foreground) + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/HTTPMethodTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/HTTPMethodTests.swift new file mode 100644 index 0000000..70580b1 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/HTTPMethodTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import SupabaseRuntime + +@Suite struct HTTPMethodTests { + @Test func rawValuesAreUppercase() { + #expect(HTTPMethod.get.rawValue == "GET") + #expect(HTTPMethod.delete.rawValue == "DELETE") + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/MockTransportTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/MockTransportTests.swift new file mode 100644 index 0000000..eeee772 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/MockTransportTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct Bucket: Codable, Equatable, Sendable { let id: String } +private struct CreateBody: Codable, Equatable, Sendable { let name: String } + +@Suite struct MockTransportTests { + @Test func sendDecodesBody() async throws { + let mock = MockTransport { _, _ in (200, #"{"id":"abc"}"#.data(using: .utf8)!) } + let bucket: Bucket = try await mock.send(HTTPRequest(method: .get, path: "/bucket/abc")) + #expect(bucket == Bucket(id: "abc")) + } + + @Test func sendBodyForwardsEncodedBody() async throws { + let box = DataBox() + let mock = MockTransport { _, body in box.set(body); return (200, #"{"id":"x"}"#.data(using: .utf8)!) } + let _: Bucket = try await mock.send(HTTPRequest(method: .post, path: "/bucket/"), body: CreateBody(name: "n")) + let captured = try #require(box.get()) + #expect(try JSONDecoder().decode(CreateBody.self, from: captured) == CreateBody(name: "n")) + } + + @Test func uploadReturnsValue() async throws { + let mock = MockTransport { _, _ in (200, #"{"id":"up"}"#.data(using: .utf8)!) } + let task: TransferTask = mock.upload(HTTPRequest(method: .post, path: "/object/b/k"), from: .data(Data())) + let bucket = try await task.value() + #expect(bucket == Bucket(id: "up")) + } + + @Test func downloadWritesBodyToFile() async throws { + let mock = MockTransport { _, _ in (200, Data([7, 8, 9])) } + let dest = FileManager.default.temporaryDirectory.appendingPathComponent("mock-\(UUID().uuidString).bin") + defer { try? FileManager.default.removeItem(at: dest) } + try await mock.download(HTTPRequest(method: .get, path: "/object/b/k"), toFile: dest).value() + #expect(try Data(contentsOf: dest) == Data([7, 8, 9])) + } + + @Test func streamYieldsBody() async throws { + let mock = MockTransport { _, _ in (200, Data([1, 2, 3])) } + let response = try await mock.stream(HTTPRequest(method: .get, path: "/events")) + #expect(response.head.status == 200) + var bytes: [UInt8] = [] + for try await chunk in response.body { bytes.append(contentsOf: chunk) } + #expect(bytes == [1, 2, 3]) + } +} + +// Lock-backed box for capturing the body from a @Sendable closure (test-only). +private final class DataBox: @unchecked Sendable { + private let lock = NSLock(); private var value: Data? + func set(_ d: Data?) { lock.lock(); value = d; lock.unlock() } + func get() -> Data? { lock.lock(); defer { lock.unlock() }; return value } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/RequestPathTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/RequestPathTests.swift new file mode 100644 index 0000000..ddb59ab --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/RequestPathTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import SupabaseRuntime + +@Suite struct RequestPathTests { + @Test func percentEncodesInterpolatedParams() { + let bucket = "my bucket" + let key = "folder/cat.png" + let path = RequestPath("/object/\(param: bucket)/\(param: key)") + #expect(path.value == "/object/my%20bucket/folder%2Fcat.png") + } + + @Test func leavesLiteralSegmentsUntouched() { + let path = RequestPath("/bucket/") + #expect(path.value == "/bucket/") + } + + @Test func encodesUnicodeParams() { + let path = RequestPath("/object/\(param: "café")") + #expect(path.value == "/object/caf%C3%A9") + } + + @Test func emptyParamYieldsEmptySegment() { + let path = RequestPath("/object/\(param: "")/end") + #expect(path.value == "/object//end") + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/Support/StubURLProtocol.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/Support/StubURLProtocol.swift new file mode 100644 index 0000000..c3a4a4c --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/Support/StubURLProtocol.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A URLProtocol returning a per-session canned response — parallel-safe (no shared mutable stub). +/// Each session is tagged with a unique id header; the protocol resolves that session's stub. +final class StubURLProtocol: URLProtocol, @unchecked Sendable { + struct Stub: Sendable { + var status: Int + var headers: [String: String] + var body: Data + init(status: Int = 200, headers: [String: String] = [:], body: Data = Data()) { + self.status = status; self.headers = headers; self.body = body + } + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var stubs: [String: Stub] = [:] + nonisolated(unsafe) private static var lastRequests: [String: URLRequest] = [:] + + /// Builds a URLSession whose requests resolve to `stub`. Returns the session and an id for `lastRequest(id:)`. + static func makeSession(stub: Stub) -> (session: URLSession, id: String) { + let id = UUID().uuidString + lock.withLock { stubs[id] = stub } + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubURLProtocol.self] + config.httpAdditionalHeaders = ["X-Stub-Id": id] + return (URLSession(configuration: config), id) + } + + static func lastRequest(id: String) -> URLRequest? { lock.withLock { lastRequests[id] } } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + override func startLoading() { + let id = request.value(forHTTPHeaderField: "X-Stub-Id") ?? "" + let stub: Stub = StubURLProtocol.lock.withLock { + StubURLProtocol.lastRequests[id] = request + return StubURLProtocol.stubs[id] ?? Stub() + } + let response = HTTPURLResponse(url: request.url!, statusCode: stub.status, httpVersion: "HTTP/1.1", headerFields: stub.headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.body) + client?.urlProtocolDidFinishLoading(self) + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/TransferTaskTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/TransferTaskTests.swift new file mode 100644 index 0000000..643e647 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/TransferTaskTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +@Suite struct TransferTaskTests { + @Test func deliversProgressThenValue() async throws { + let (stream, cont) = AsyncStream.makeStream() + let task = TransferTask( + progress: stream, + value: { 42 }, + cancel: {} + ) + cont.yield(TransferProgress(completed: 5, total: 10)) + cont.finish() + + var seen: [Int64] = [] + for await p in task.progress { seen.append(p.completed) } + let value = try await task.value() + + #expect(seen == [5]) + #expect(value == 42) + } + + @Test func fractionComputesWhenTotalKnown() { + #expect(TransferProgress(completed: 5, total: 10).fraction == 0.5) + #expect(TransferProgress(completed: 5, total: nil).fraction == nil) + #expect(TransferProgress(completed: 1, total: 0).fraction == nil) + } + + @Test func cancelInvokesClosure() async { + let flag = Flag() + let task = TransferTask(progress: AsyncStream { $0.finish() }, value: { 0 }, cancel: { flag.set() }) + task.cancel() + #expect(flag.get() == true) + } +} + +// Minimal lock-backed helper for the cancel test (test-only). +private final class Flag: @unchecked Sendable { + private let lock = NSLock(); private var value = false + func set() { lock.lock(); value = true; lock.unlock() } + func get() -> Bool { lock.lock(); defer { lock.unlock() }; return value } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportSendTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportSendTests.swift new file mode 100644 index 0000000..8b6f069 --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportSendTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct Bucket: Codable, Equatable, Sendable { let id: String } +private struct StorageError: Error, Equatable { let message: String } + +@Suite struct URLSessionTransportSendTests { + func makeTransport(stub: StubURLProtocol.Stub, errorMapper: (@Sendable (Data, HTTPResponseHead) -> (any Error)?)? = nil) -> (URLSessionTransport, String) { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/storage/v1")!) + if let errorMapper { config.errorMapper = errorMapper } + config.auth = AuthProvider { ["apikey": "k"] } + let (session, id) = StubURLProtocol.makeSession(stub: stub) + return (URLSessionTransport(configuration: config, urlSession: session), id) + } + + @Test func sendDecodes2xx() async throws { + let (transport, _) = makeTransport(stub: .init(status: 200, headers: ["Content-Type": "application/json"], body: #"{"id":"abc"}"#.data(using: .utf8)!)) + let bucket: Bucket = try await transport.send(HTTPRequest(method: .get, path: "/bucket/abc")) + #expect(bucket == Bucket(id: "abc")) + } + + @Test func sendBuildsURLWithBaseAndAuthHeader() async throws { + let (transport, id) = makeTransport(stub: .init(body: #"{"id":"x"}"#.data(using: .utf8)!)) + let _: Bucket = try await transport.send(HTTPRequest(method: .get, path: "/bucket/x", query: [URLQueryItem(name: "limit", value: "10")])) + let req = try #require(StubURLProtocol.lastRequest(id: id)) + #expect(req.url?.absoluteString == "https://x.test/storage/v1/bucket/x?limit=10") + #expect(req.value(forHTTPHeaderField: "apikey") == "k") + } + + @Test func sendMapsNon2xxToTypedError() async { + let (transport, _) = makeTransport(stub: .init(status: 400, body: #"{"message":"bad"}"#.data(using: .utf8)!)) { data, _ in + (try? JSONDecoder().decode([String: String].self, from: data)["message"]).map { StorageError(message: $0) } ?? nil + } + await #expect(throws: StorageError(message: "bad")) { + let _: Bucket = try await transport.send(HTTPRequest(method: .get, path: "/bucket/x")) + } + } + + @Test func sendBodySetsJSONContentTypeAndDecodes() async throws { + struct CreateBody: Encodable, Sendable { let name: String } + let (transport, id) = makeTransport(stub: .init(body: #"{"id":"made"}"#.data(using: .utf8)!)) + let bucket: Bucket = try await transport.send(HTTPRequest(method: .post, path: "/bucket/"), body: CreateBody(name: "n")) + #expect(bucket == Bucket(id: "made")) + #expect(StubURLProtocol.lastRequest(id: id)?.value(forHTTPHeaderField: "Content-Type") == "application/json") + } + + @Test func noContentSendSucceedsOn2xxAndThrowsOnError() async throws { + let (ok, _) = makeTransport(stub: .init(status: 204)) + try await ok.send(HTTPRequest(method: .delete, path: "/bucket/x")) + let (bad, _) = makeTransport(stub: .init(status: 500)) + await #expect(throws: (any Error).self) { try await bad.send(HTTPRequest(method: .delete, path: "/bucket/x")) } + } + + @Test func trailingSlashBaseURLDoesNotDoubleSlash() async throws { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/storage/v1/")!) + config.auth = AuthProvider { [:] } + let (session, id) = StubURLProtocol.makeSession(stub: .init(body: #"{"id":"x"}"#.data(using: .utf8)!)) + let transport = URLSessionTransport(configuration: config, urlSession: session) + let _: Bucket = try await transport.send(HTTPRequest(method: .get, path: "/bucket/x")) + #expect(StubURLProtocol.lastRequest(id: id)?.url?.absoluteString == "https://x.test/storage/v1/bucket/x") + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportStreamTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportStreamTests.swift new file mode 100644 index 0000000..f9136fd --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportStreamTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +@Suite struct URLSessionTransportStreamTests { + @Test func streamYieldsBodyBytesAndHead() async throws { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/functions/v1")!) + config.auth = AuthProvider { ["apikey": "k"] } + let (session, _) = StubURLProtocol.makeSession(stub: .init(status: 200, headers: ["Content-Type": "text/event-stream"], body: Data([0x64, 0x61, 0x74, 0x61]))) + let transport = URLSessionTransport(configuration: config, urlSession: session) + let response = try await transport.stream(HTTPRequest(method: .get, path: "/events")) + #expect(response.head.status == 200) + var bytes: [UInt8] = [] + for try await chunk in response.body { bytes.append(contentsOf: chunk) } + #expect(bytes == [0x64, 0x61, 0x74, 0x61]) + } + + @Test func streamMapsNon2xxToError() async { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/functions/v1")!) + let (session, _) = StubURLProtocol.makeSession(stub: .init(status: 500, headers: [:], body: Data([0x65]))) + let transport = URLSessionTransport(configuration: config, urlSession: session) + await #expect(throws: TransportError.self) { + _ = try await transport.stream(HTTPRequest(method: .get, path: "/events")) + } + } + + @Test func streamRunsErrorMapperOnNon2xx() async { + struct StreamError: Error, Equatable { let code: Int } + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/functions/v1")!) + config.errorMapper = { _, head in StreamError(code: head.status) } + let (session, _) = StubURLProtocol.makeSession(stub: .init(status: 503, headers: [:], body: Data())) + let transport = URLSessionTransport(configuration: config, urlSession: session) + await #expect(throws: StreamError(code: 503)) { + _ = try await transport.stream(HTTPRequest(method: .get, path: "/events")) + } + } +} diff --git a/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportTransferTests.swift b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportTransferTests.swift new file mode 100644 index 0000000..6f9be2a --- /dev/null +++ b/codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportTransferTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct UploadResult: Codable, Equatable, Sendable { let key: String } + +@Suite struct URLSessionTransportTransferTests { + func makeTransport(stub: StubURLProtocol.Stub) -> URLSessionTransport { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/storage/v1")!) + config.auth = AuthProvider { ["apikey": "k"] } + let (session, _) = StubURLProtocol.makeSession(stub: stub) + return URLSessionTransport(configuration: config, urlSession: session) + } + + @Test func uploadFromDataReturnsDecodedValue() async throws { + let task: TransferTask = makeTransport(stub: .init(body: #"{"key":"folder/cat.png"}"#.data(using: .utf8)!)) + .upload(HTTPRequest(method: .post, path: "/object/avatars/cat.png"), from: .data(Data([0x1, 0x2]))) + for await _ in task.progress {} + #expect(try await task.value() == UploadResult(key: "folder/cat.png")) + } + + @Test func uploadProgressStreamTerminates() async throws { + let task: TransferTask = makeTransport(stub: .init(body: #"{"key":"k"}"#.data(using: .utf8)!)) + .upload(HTTPRequest(method: .post, path: "/object/b/k"), from: .data(Data([1]))) + var count = 0 + for await _ in task.progress { count += 1 } // must terminate (count may be 0 under the stub) + _ = try await task.value() + #expect(count >= 0) + } + + @Test func downloadWritesToFile() async throws { + let dest = FileManager.default.temporaryDirectory.appendingPathComponent("dl-\(UUID().uuidString).bin") + defer { try? FileManager.default.removeItem(at: dest) } + let task = makeTransport(stub: .init(body: Data([0xA, 0xB, 0xC]))) + .download(HTTPRequest(method: .get, path: "/object/avatars/cat.png"), toFile: dest) + for await _ in task.progress {} + try await task.value() + #expect((try? Data(contentsOf: dest)) == Data([0xA, 0xB, 0xC])) + } +} diff --git a/codegen/specs/storage.normalized.json b/codegen/specs/storage.normalized.json new file mode 100644 index 0000000..e3a7d12 --- /dev/null +++ b/codegen/specs/storage.normalized.json @@ -0,0 +1,7105 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Supabase Storage API", + "description": "API documentation for Supabase Storage", + "version": "0.0.0" + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "jwt" + } + }, + "schemas": { + "AuthHeader": { + "type": "object", + "properties": { + "authorization": { + "type": "string", + "example": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs" + } + }, + "required": [ + "authorization" + ], + "title": "authSchema" + }, + "ErrorBody": { + "type": "object", + "properties": { + "statusCode": { + "type": "string" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "statusCode", + "error", + "message" + ], + "title": "errorSchema" + } + } + }, + "paths": { + "/upload/resumable/": { + "post": { + "summary": "Handle POST request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postUploadResumable" + }, + "options": { + "summary": "Handle OPTIONS request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "description": "Handle OPTIONS request for TUS Resumable uploads", + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "optionsUploadResumable" + } + }, + "/upload/resumable/sign/": { + "post": { + "summary": "Handle POST request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postUploadResumableSign" + }, + "options": { + "summary": "Handle OPTIONS request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "description": "Handle OPTIONS request for TUS Resumable uploads", + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "optionsUploadResumableSign" + } + }, + "/bucket/": { + "post": { + "summary": "Create a bucket", + "tags": [ + "bucket" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "avatars" + }, + "id": { + "type": "string", + "example": "avatars" + }, + "public": { + "type": "boolean", + "example": false + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "anyOf": [ + { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + { + "type": "string", + "nullable": true + } + ] + }, + "allowed_mime_types": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "example": [ + "image/png", + "image/jpg" + ] + } + }, + "required": [ + "name" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "avatars" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "createBucket" + }, + "get": { + "summary": "Gets all buckets", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 0, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ] + }, + "in": "query", + "name": "sortColumn", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "in": "query", + "name": "sortOrder", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "my-bucket", + "in": "query", + "name": "search", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "title": "bucketSchema", + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "example": [ + { + "id": "avatars", + "type": "STANDARD", + "name": "avatars", + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + ] + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "listBuckets" + }, + "head": { + "summary": "Gets all buckets", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 0, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ] + }, + "in": "query", + "name": "sortColumn", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "in": "query", + "name": "sortOrder", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "my-bucket", + "in": "query", + "name": "search", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "example": [ + { + "id": "avatars", + "type": "STANDARD", + "name": "avatars", + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + ] + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headBucket" + } + }, + "/bucket/{bucketId}/empty": { + "post": { + "summary": "Empty a bucket", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketId", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Empty bucket has been queued. Completion may take up to an hour." + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postBucketByBucketIdEmpty" + } + }, + "/bucket": { + "head": { + "summary": "Gets all buckets", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 0, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ] + }, + "in": "query", + "name": "sortColumn", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "in": "query", + "name": "sortOrder", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "my-bucket", + "in": "query", + "name": "search", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "example": [ + { + "id": "avatars", + "type": "STANDARD", + "name": "avatars", + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + ] + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headBucket2" + } + }, + "/bucket/{bucketId}": { + "get": { + "summary": "Get details of a bucket", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketId", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getBucket" + }, + "head": { + "summary": "Get details of a bucket", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketId", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headBucketByBucketId" + }, + "put": { + "summary": "Update properties of a bucket", + "tags": [ + "bucket" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "minProperties": 1, + "properties": { + "public": { + "type": "boolean", + "example": false + }, + "file_size_limit": { + "anyOf": [ + { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + { + "type": "string", + "nullable": true + } + ] + }, + "allowed_mime_types": { + "type": "array", + "nullable": true, + "items": { + "type": "string", + "example": [ + "image/png", + "image/jpg" + ] + } + } + }, + "anyOf": [ + { + "required": [ + "public" + ] + }, + { + "required": [ + "file_size_limit" + ] + }, + { + "required": [ + "allowed_mime_types" + ] + } + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "bucketId", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully updated" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "putBucketByBucketId" + }, + "delete": { + "summary": "Delete a bucket", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketId", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted" + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "deleteBucket" + } + }, + "/object/{bucketName}": { + "delete": { + "summary": "Delete multiple objects", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prefixes": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "example": [ + "folder/cat.png", + "folder/morecats.png" + ] + } + }, + "required": [ + "prefixes" + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket_id": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "last_accessed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "user_metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "buckets": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "objectSchema", + "example": { + "name": "folder/cat.png", + "bucket_id": "avatars", + "owner": "317eadce-631a-4429-a0bb-f19a7a517b4a", + "id": "eaa8bdb5-2e00-4767-b5a9-d2502efe2196", + "updated_at": "2021-04-06T16:30:35.394674+00:00", + "created_at": "2021-04-06T16:30:35.394674+00:00", + "last_accessed_at": "2021-04-06T16:30:35.394674+00:00", + "metadata": { + "size": 1234 + } + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "deleteObjectByBucketName" + } + }, + "/object/sign/{bucketName}": { + "post": { + "summary": "Generate presigned urls to retrieve objects", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expiresIn": { + "type": "integer", + "minimum": 1, + "example": 60000 + }, + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "example": [ + "folder/cat.png", + "folder/morecats.png" + ] + } + }, + "required": [ + "expiresIn", + "paths" + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Either the object does not exist or you do not have access to it", + "nullable": true + }, + "path": { + "type": "string", + "example": "folder/cat.png" + }, + "signedURL": { + "type": "string", + "example": "/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4", + "nullable": true + } + }, + "required": [ + "error", + "path", + "signedURL" + ] + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectSignByBucketName" + } + }, + "/object/move": { + "post": { + "summary": "Moves an object", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bucketId": { + "type": "string", + "example": "avatars" + }, + "sourceKey": { + "type": "string", + "example": "folder/cat.png" + }, + "destinationBucket": { + "type": "string", + "example": "users" + }, + "destinationKey": { + "type": "string", + "example": "folder/newcat.png" + } + }, + "required": [ + "bucketId", + "sourceKey", + "destinationKey" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully moved" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectMove" + } + }, + "/object/list-v2/{bucketName}": { + "post": { + "summary": "Search for objects under a prefix", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "example": "folder/subfolder" + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 10 + }, + "cursor": { + "type": "string" + }, + "with_delimiter": { + "type": "boolean" + }, + "sortBy": { + "type": "object", + "properties": { + "column": { + "type": "string", + "enum": [ + "name", + "updated_at", + "created_at" + ] + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "column" + ] + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "bucketName", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postObjectList-v2ByBucketName" + } + }, + "/object/list/{bucketName}": { + "post": { + "summary": "Search for objects under a prefix", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "example": "folder/subfolder" + }, + "limit": { + "type": "integer", + "minimum": 1, + "example": 10 + }, + "offset": { + "type": "integer", + "minimum": 0, + "example": 0 + }, + "sortBy": { + "type": "object", + "properties": { + "column": { + "type": "string", + "enum": [ + "name", + "updated_at", + "created_at", + "last_accessed_at" + ] + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "column" + ] + }, + "search": { + "type": "string" + } + }, + "required": [ + "prefix" + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "bucketName", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket_id": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "last_accessed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "user_metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "buckets": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "example": { + "name": "folder/cat.png", + "bucket_id": "avatars", + "owner": "317eadce-631a-4429-a0bb-f19a7a517b4a", + "id": "eaa8bdb5-2e00-4767-b5a9-d2502efe2196", + "updated_at": "2021-04-06T16:30:35.394674+00:00", + "created_at": "2021-04-06T16:30:35.394674+00:00", + "last_accessed_at": "2021-04-06T16:30:35.394674+00:00", + "metadata": { + "size": 1234 + } + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectListByBucketName" + } + }, + "/object/copy": { + "post": { + "summary": "Copies an object", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bucketId": { + "type": "string", + "example": "avatars" + }, + "sourceKey": { + "type": "string", + "example": "folder/source.png" + }, + "destinationBucket": { + "type": "string", + "example": "users" + }, + "destinationKey": { + "type": "string", + "example": "folder/destination.png" + }, + "metadata": { + "type": "object", + "properties": { + "cacheControl": { + "type": "string" + }, + "mimetype": { + "type": "string" + } + } + }, + "copyMetadata": { + "type": "boolean", + "example": true + } + }, + "required": [ + "sourceKey", + "bucketId", + "destinationKey" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "Key": { + "type": "string", + "example": "folder/destination.png" + }, + "name": { + "type": "string" + }, + "bucket_id": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "last_accessed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "user_metadata": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "buckets": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "type": { + "type": "string", + "enum": [ + "STANDARD", + "ANALYTICS" + ] + }, + "file_size_limit": { + "type": "integer", + "nullable": true + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false, + "example": { + "id": "bucket2", + "name": "bucket2", + "public": false, + "file_size_limit": 1000000, + "allowed_mime_types": [ + "image/png", + "image/jpeg" + ], + "owner": "4d56e902-f0a0-4662-8448-a4d9e643c142", + "created_at": "2021-02-17T04:43:32.770206+00:00", + "updated_at": "2021-02-17T04:43:32.770206+00:00" + } + } + }, + "required": [ + "Key" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectCopy" + } + }, + "/s3/{Bucket}": { + "delete": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteS3ByBucket" + }, + "put": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "putS3ByBucket" + }, + "post": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postS3ByBucket" + }, + "get": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getS3ByBucket" + }, + "head": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headS3ByBucket" + } + }, + "/s3/{Bucket}/": { + "delete": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteS3ByBucket2" + }, + "put": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "putS3ByBucket2" + }, + "post": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postS3ByBucket2" + }, + "get": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getS3ByBucket2" + }, + "head": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headS3ByBucket2" + } + }, + "/s3/": { + "get": { + "tags": [ + "s3" + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getS3" + } + }, + "/health/": { + "get": { + "summary": "healthcheck", + "tags": [ + "health" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getHealth" + }, + "head": { + "summary": "healthcheck", + "tags": [ + "health" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headHealth" + } + }, + "/health": { + "head": { + "summary": "healthcheck", + "tags": [ + "health" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headHealth2" + } + }, + "/iceberg/bucket": { + "post": { + "summary": "Create an analytics bucket", + "tags": [ + "bucket" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "avatars" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postIcebergBucket" + }, + "get": { + "summary": "List analytics buckets", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 0, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ] + }, + "in": "query", + "name": "sortColumn", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "in": "query", + "name": "sortOrder", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "my-bucket", + "in": "query", + "name": "search", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getIcebergBucket" + }, + "head": { + "summary": "List analytics buckets", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "example": 10, + "in": "query", + "name": "limit", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 0, + "in": "query", + "name": "offset", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "id", + "name", + "created_at", + "updated_at" + ] + }, + "in": "query", + "name": "sortColumn", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "in": "query", + "name": "sortOrder", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "my-bucket", + "in": "query", + "name": "search", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headIcebergBucket" + } + }, + "/iceberg/bucket/{bucketName}": { + "delete": { + "summary": "Delete an analytics bucket", + "tags": [ + "bucket" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteIcebergBucketByBucketName" + } + }, + "/iceberg/v1/config": { + "get": { + "summary": "Get Iceberg catalog configuration", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "my-warehouse", + "in": "query", + "name": "warehouse", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getIcebergV1Config" + }, + "head": { + "summary": "Get Iceberg catalog configuration", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "my-warehouse", + "in": "query", + "name": "warehouse", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headIcebergV1Config" + } + }, + "/iceberg/v1/{prefix}/namespaces": { + "post": { + "summary": "Create a namespace", + "tags": [ + "iceberg" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "namespace": { + "type": "string", + "example": "namespace" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "namespace" + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postIcebergV1ByPrefixNamespaces" + }, + "get": { + "summary": "List namespaces", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "pageToken", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "pageSize", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "parent", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getIcebergV1ByPrefixNamespaces" + }, + "head": { + "summary": "List namespaces", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "pageToken", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "pageSize", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "parent", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headIcebergV1ByPrefixNamespaces" + } + }, + "/iceberg/v1/{prefix}/namespaces/{namespace}": { + "head": { + "summary": "Load a namespace", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headIcebergV1ByPrefixNamespacesByNamespace" + }, + "get": { + "summary": "Load a namespace", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getIcebergV1ByPrefixNamespacesByNamespace" + }, + "delete": { + "summary": "Drop a namespace", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteIcebergV1ByPrefixNamespacesByNamespace" + } + }, + "/iceberg/v1/{prefix}/namespaces/{namespace}/tables": { + "post": { + "summary": "Create a table in the given namespace", + "tags": [ + "iceberg" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "schema" + ], + "properties": { + "name": { + "type": "string" + }, + "location": { + "type": "string", + "format": "uri", + "nullable": true + }, + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "type", + "fields" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "struct" + ] + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type", + "required" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "type", + "fields" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "struct" + ] + }, + "fields": { + "type": "array", + "items": {} + } + } + }, + { + "type": "object", + "required": [ + "type", + "element-id", + "element", + "element-required" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "list" + ] + }, + "element-id": { + "type": "integer" + }, + "element": {}, + "element-required": { + "type": "boolean" + } + } + }, + { + "type": "object", + "required": [ + "type", + "key-id", + "key", + "value-id", + "value", + "value-required" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "map" + ] + }, + "key-id": { + "type": "integer" + }, + "key": {}, + "value-id": { + "type": "integer" + }, + "value": {}, + "value-required": { + "type": "boolean" + } + } + } + ] + }, + "required": { + "type": "boolean" + }, + "doc": { + "type": "string" + } + } + } + } + } + }, + { + "type": "object", + "properties": { + "schema-id": { + "type": "integer", + "readOnly": true + }, + "identifier-field-ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + ] + }, + "spec": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "spec-id": { + "type": "integer", + "readOnly": true + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source-id", + "transform", + "name" + ], + "properties": { + "field-id": { + "type": "integer" + }, + "source-id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "transform": { + "type": "string" + } + } + } + } + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "stage-create": { + "type": "boolean", + "default": false + }, + "write-order": { + "type": "object", + "nullable": true, + "required": [ + "fields" + ], + "properties": { + "order-id": { + "type": "integer", + "readOnly": true + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source-id", + "transform", + "direction", + "null-order" + ], + "properties": { + "source-id": { + "type": "integer" + }, + "transform": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "null-order": { + "type": "string", + "enum": [ + "nulls-first", + "nulls-last" + ] + } + } + } + } + } + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "postIcebergV1ByPrefixNamespacesByNamespaceTables" + }, + "get": { + "summary": "Create a table", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "pageToken", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "pageSize", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "parent", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "getIcebergV1ByPrefixNamespacesByNamespaceTables" + }, + "head": { + "summary": "Create a table", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "pageToken", + "required": false + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "pageSize", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "parent", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "headIcebergV1ByPrefixNamespacesByNamespaceTables" + } + }, + "/iceberg/v1/{prefix}/namespaces/{namespace}/tables/{table}": { + "get": { + "summary": "Load an Iceberg Table", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "table", + "in": "path", + "name": "table", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "getIcebergV1ByPrefixNamespacesByNamespaceTablesByTable" + }, + "head": { + "summary": "Load an Iceberg Table", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "table", + "in": "path", + "name": "table", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "headIcebergV1ByPrefixNamespacesByNamespaceTablesByTable" + }, + "delete": { + "summary": "Drop a Table", + "tags": [ + "iceberg" + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "true", + "false", + "True", + "False" + ], + "default": "false" + }, + "in": "query", + "name": "purgeRequested", + "required": false, + "description": "If true, the table will be permanently deleted" + }, + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "table", + "in": "path", + "name": "table", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteIcebergV1ByPrefixNamespacesByNamespaceTablesByTable" + }, + "post": { + "summary": "Commit updates to multiple tables in an atomic operation", + "tags": [ + "iceberg" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Commit updates to multiple tables in an atomic operation", + "properties": { + "requirements": { + "type": "array", + "description": "Assertions to validate before applying updates", + "items": { + "type": "object", + "description": "A requirement assertion", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Type of the requirement (e.g. assert-ref-snapshot-id, assert-table-uuid)", + "example": "assert-ref-snapshot-id" + }, + "ref": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "args": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "updates": { + "type": "array", + "description": "Metadata updates to apply to the table", + "items": { + "type": "object", + "description": "A single update operation", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "Action to perform (e.g. add-snapshot, set-snapshot-ref)", + "example": "add-snapshot" + }, + "snapshot": { + "type": "object", + "properties": { + "sequence-number": { + "type": "integer" + }, + "timestamp-ms": { + "type": "integer" + }, + "manifest-list": { + "type": "string" + }, + "summary": { + "type": "object", + "additionalProperties": true, + "properties": { + "operation": { + "type": "string" + }, + "added-files-size": { + "type": "string" + }, + "added-data-files": { + "type": "string" + }, + "added-records": { + "type": "string" + }, + "total-delete-files": { + "type": "string" + }, + "total-records": { + "type": "string" + }, + "total-position-deletes": { + "type": "string" + }, + "total-equality-deletes": { + "type": "string" + } + } + }, + "schema-id": { + "type": "integer" + } + }, + "additionalProperties": true + }, + "ref-name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "args": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + } + }, + "required": [ + "updates", + "requirements" + ] + } + } + }, + "description": "Commit updates to multiple tables in an atomic operation" + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "prefix", + "in": "path", + "name": "prefix", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "namespace", + "in": "path", + "name": "namespace", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "table", + "in": "path", + "name": "table", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "operationId": "postIcebergV1ByPrefixNamespacesByNamespaceTablesByTable" + } + }, + "/vector/CreateIndex": { + "post": { + "summary": "Create a vector index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "dataType": { + "type": "string", + "enum": [ + "float32" + ] + }, + "dimension": { + "type": "number", + "minimum": 1, + "maximum": 4096 + }, + "distanceMetric": { + "type": "string", + "enum": [ + "cosine", + "euclidean" + ] + }, + "indexName": { + "type": "string", + "minLength": 3, + "maxLength": 45, + "pattern": "^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$", + "description": "3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket." + }, + "metadataConfiguration": { + "type": "object", + "required": [ + "nonFilterableMetadataKeys" + ], + "properties": { + "nonFilterableMetadataKeys": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "dataType", + "dimension", + "distanceMetric", + "indexName", + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorCreateIndex" + } + }, + "/vector/DeleteIndex": { + "post": { + "summary": "Delete a vector index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "indexName": { + "type": "string", + "minLength": 3, + "maxLength": 45, + "pattern": "^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$", + "description": "3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket." + }, + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "indexName", + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorDeleteIndex" + } + }, + "/vector/ListIndexes": { + "post": { + "summary": "List indexes in a vector bucket", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + }, + "maxResults": { + "type": "number", + "minimum": 1, + "maximum": 500, + "default": 500 + }, + "nextToken": { + "type": "string" + }, + "prefix": { + "type": "string" + } + }, + "required": [ + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorListIndexes" + } + }, + "/vector/GetIndex": { + "post": { + "summary": "Get a vector index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + }, + "indexName": { + "type": "string", + "minLength": 3, + "maxLength": 45, + "pattern": "^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$", + "description": "3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket." + } + }, + "required": [ + "vectorBucketName", + "indexName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorGetIndex" + } + }, + "/vector/CreateVectorBucket": { + "post": { + "summary": "Create a vector bucket", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorCreateVectorBucket" + } + }, + "/vector/DeleteVectorBucket": { + "post": { + "summary": "Create a vector bucket", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorDeleteVectorBucket" + } + }, + "/vector/ListVectorBuckets": { + "post": { + "summary": "List vector buckets", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "maxResults": { + "type": "number", + "minimum": 1, + "maximum": 500, + "default": 500 + }, + "nextToken": { + "type": "string" + }, + "prefix": { + "type": "string" + } + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorListVectorBuckets" + } + }, + "/vector/GetVectorBucket": { + "post": { + "summary": "Create a vector bucket", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorGetVectorBucket" + } + }, + "/vector/PutVectors": { + "post": { + "summary": "Put vectors into an index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + }, + "indexName": { + "type": "string", + "minLength": 3, + "maxLength": 45, + "pattern": "^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$", + "description": "3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket." + }, + "vectors": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "float32": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "float32" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ] + } + }, + "key": { + "type": "string" + } + }, + "required": [ + "data" + ] + } + } + }, + "required": [ + "vectorBucketName", + "indexName", + "vectors" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorPutVectors" + } + }, + "/vector/QueryVectors": { + "post": { + "summary": "Query vectors", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "x-inlined-from": "https://schemas.example.com/queryVectorBody.json" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorQueryVectors" + } + }, + "/vector/DeleteVectors": { + "post": { + "summary": "Delete vectors from an index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + }, + "indexName": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "vectorBucketName", + "indexName", + "keys" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorDeleteVectors" + } + }, + "/vector/ListVectors": { + "post": { + "summary": "List vectors in a vector index", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vectorBucketName": { + "type": "string" + }, + "indexArn": { + "type": "string" + }, + "indexName": { + "type": "string", + "minLength": 3, + "maxLength": 45, + "pattern": "^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$", + "description": "3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket." + }, + "maxResults": { + "type": "number", + "minimum": 1, + "maximum": 500 + }, + "nextToken": { + "type": "string" + }, + "returnData": { + "type": "boolean" + }, + "returnMetadata": { + "type": "boolean" + }, + "segmentCount": { + "type": "number", + "minimum": 1, + "maximum": 16 + }, + "segmentIndex": { + "type": "number", + "minimum": 0, + "maximum": 15 + } + }, + "required": [ + "vectorBucketName", + "indexName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorListVectors" + } + }, + "/vector/GetVectors": { + "post": { + "summary": "Returns vector attributes", + "tags": [ + "vector" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "indexName": { + "type": "string" + }, + "keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "returnData": { + "type": "boolean", + "default": false + }, + "returnMetadata": { + "type": "boolean", + "default": false + }, + "vectorBucketName": { + "type": "string" + } + }, + "required": [ + "indexName", + "keys", + "vectorBucketName" + ] + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postVectorGetVectors" + } + }, + "/upload/resumable/{objectPath}": { + "post": { + "summary": "Handle POST request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postUploadResumableByObjectPath" + }, + "put": { + "summary": "Handle PUT request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "putUploadResumableByObjectPath" + }, + "patch": { + "summary": "Handle PATCH request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "patchUploadResumableByObjectPath" + }, + "head": { + "summary": "Handle HEAD request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headUploadResumableByObjectPath" + }, + "delete": { + "summary": "Handle DELETE request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteUploadResumableByObjectPath" + }, + "options": { + "summary": "Handle OPTIONS request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "description": "Handle OPTIONS request for TUS Resumable uploads", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "optionsUploadResumableByObjectPath" + } + }, + "/upload/resumable/sign/{objectPath}": { + "post": { + "summary": "Handle POST request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postUploadResumableSignByObjectPath" + }, + "put": { + "summary": "Handle PUT request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "putUploadResumableSignByObjectPath" + }, + "patch": { + "summary": "Handle PATCH request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "patchUploadResumableSignByObjectPath" + }, + "head": { + "summary": "Handle HEAD request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headUploadResumableSignByObjectPath" + }, + "delete": { + "summary": "Handle DELETE request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteUploadResumableSignByObjectPath" + }, + "options": { + "summary": "Handle OPTIONS request for TUS Resumable uploads", + "tags": [ + "resumable" + ], + "description": "Handle OPTIONS request for TUS Resumable uploads", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "optionsUploadResumableSignByObjectPath" + } + }, + "/object/{bucketName}/{objectPath}": { + "delete": { + "summary": "Delete an object", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted" + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "deleteObjectByBucketNameByObjectPath" + }, + "get": { + "summary": "Get object", + "tags": [ + "object" + ], + "description": "Serve objects", + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectByBucketNameByObjectPath" + }, + "put": { + "summary": "Update the object at an existing key", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Key": { + "type": "string", + "example": "avatars/folder/cat.png" + } + }, + "required": [ + "Key" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "putObjectByBucketNameByObjectPath", + "requestBody": { + "required": true, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "head": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "description": "Head object info", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectByBucketNameByObjectPath" + }, + "post": { + "summary": "Upload a new object", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Key": { + "type": "string", + "example": "avatars/folder/cat.png" + } + }, + "required": [ + "Key" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "uploadObject", + "requestBody": { + "required": true, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "/object/authenticated/{bucketName}/{objectPath}": { + "get": { + "summary": "Retrieve an object", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "filename.jpg": { + "value": "filename.jpg" + }, + "example2": { + "value": null + } + }, + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectAuthenticatedByBucketNameByObjectPath" + }, + "head": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectAuthenticatedByBucketNameByObjectPath" + } + }, + "/object/upload/sign/{bucketName}/{objectPath}": { + "post": { + "summary": "Generate a presigned url to upload an object", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "/object/sign/upload/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4" + }, + "token": { + "type": "string" + } + }, + "required": [ + "url" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectUploadSignByBucketNameByObjectPath" + }, + "put": { + "summary": "Uploads an object via a presigned URL", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk", + "in": "query", + "name": "token", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "Key": { + "type": "string", + "example": "avatars/folder/cat.png" + } + }, + "required": [ + "Key" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "putObjectUploadSignByBucketNameByObjectPath" + } + }, + "/object/sign/{bucketName}/{objectPath}": { + "post": { + "summary": "Generate a presigned url to retrieve an object", + "tags": [ + "object" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expiresIn": { + "type": "integer", + "minimum": 1, + "example": 60000 + }, + "transform": { + "type": "object", + "properties": { + "height": { + "type": "integer", + "minimum": 0, + "example": 100 + }, + "width": { + "type": "integer", + "minimum": 0, + "example": 100 + }, + "resize": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "format": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "quality": { + "type": "integer", + "minimum": 20, + "maximum": 100 + } + } + } + }, + "required": [ + "expiresIn" + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "signedURL": { + "type": "string", + "example": "/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4" + } + }, + "required": [ + "signedURL" + ] + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "postObjectSignByBucketNameByObjectPath" + }, + "get": { + "summary": "Retrieve an object via a presigned URL", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "filename.jpg": { + "value": "filename.jpg" + }, + "example2": { + "value": null + } + }, + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk", + "in": "query", + "name": "token", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectSignByBucketNameByObjectPath" + }, + "head": { + "summary": "Retrieve an object via a presigned URL", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "filename.jpg": { + "value": "filename.jpg" + }, + "example2": { + "value": null + } + }, + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk", + "in": "query", + "name": "token", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectSignByBucketNameByObjectPath" + } + }, + "/object/info/authenticated/{bucketName}/{objectPath}": { + "get": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectInfoAuthenticatedByBucketNameByObjectPath" + }, + "head": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectInfoAuthenticatedByBucketNameByObjectPath" + } + }, + "/object/info/{bucketName}/{objectPath}": { + "get": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "description": "Object Info", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectInfoByBucketNameByObjectPath" + }, + "head": { + "summary": "Retrieve object info", + "tags": [ + "object" + ], + "description": "Object Info", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectInfoByBucketNameByObjectPath" + } + }, + "/object/public/{bucketName}/{objectPath}": { + "get": { + "summary": "Retrieve an object from a public bucket", + "tags": [ + "object" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "filename.jpg": { + "value": "filename.jpg" + }, + "example2": { + "value": null + } + }, + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectPublicByBucketNameByObjectPath" + }, + "head": { + "summary": "Get object info", + "tags": [ + "object" + ], + "description": "returns object info", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headObjectPublicByBucketNameByObjectPath" + } + }, + "/object/info/public/{bucketName}/{objectPath}": { + "get": { + "summary": "Get object info", + "tags": [ + "object" + ], + "description": "returns object info", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getObjectInfoPublicByBucketNameByObjectPath" + } + }, + "/render/image/authenticated/{bucketName}/{objectPath}": { + "get": { + "summary": "Render an authenticated image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getRenderImageAuthenticatedByBucketNameByObjectPath" + }, + "head": { + "summary": "Render an authenticated image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headRenderImageAuthenticatedByBucketNameByObjectPath" + } + }, + "/render/image/sign/{bucketName}/{objectPath}": { + "get": { + "summary": "Render an authenticated image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk", + "in": "query", + "name": "token", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getRenderImageSignByBucketNameByObjectPath" + }, + "head": { + "summary": "Render an authenticated image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk", + "in": "query", + "name": "token", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headRenderImageSignByBucketNameByObjectPath" + } + }, + "/render/image/public/{bucketName}/{objectPath}": { + "get": { + "summary": "Render a public image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "getRenderImagePublicByBucketNameByObjectPath" + }, + "head": { + "summary": "Render a public image with the given transformations", + "tags": [ + "transformation" + ], + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "height", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 100, + "in": "query", + "name": "width", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "cover", + "contain", + "fill" + ] + }, + "in": "query", + "name": "resize", + "required": false + }, + { + "schema": { + "type": "string", + "enum": [ + "origin", + "avif", + "webp" + ] + }, + "in": "query", + "name": "format", + "required": false + }, + { + "schema": { + "type": "integer", + "minimum": 20, + "maximum": 100 + }, + "in": "query", + "name": "quality", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "filename.png", + "in": "query", + "name": "download", + "required": false + }, + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "headRenderImagePublicByBucketNameByObjectPath" + } + }, + "/s3/{Bucket}/{objectPath}": { + "put": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "putS3ByBucketByObjectPath" + }, + "head": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "headS3ByBucketByObjectPath" + }, + "post": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "postS3ByBucketByObjectPath" + }, + "delete": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "deleteS3ByBucketByObjectPath" + }, + "get": { + "tags": [ + "s3" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "Bucket", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "objectPath", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "operationId": "getS3ByBucketByObjectPath" + } + }, + "/cdn/{bucketName}/{objectPath}": { + "delete": { + "summary": "Purge cache for an object", + "tags": [ + "cdn" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "example": "avatars", + "in": "path", + "name": "bucketName", + "required": true + }, + { + "schema": { + "type": "string" + }, + "example": "folder/cat.png", + "in": "path", + "name": "objectPath", + "required": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "description": "Successful response", + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "success" + } + } + } + } + } + }, + "4XX": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + } + } + }, + "operationId": "deleteCdnByBucketNameByObjectPath" + } + } + }, + "tags": [ + { + "name": "object", + "description": "Object end-points" + }, + { + "name": "bucket", + "description": "Bucket end-points" + }, + { + "name": "s3", + "description": "S3 end-points" + }, + { + "name": "transformation", + "description": "Image transformation" + }, + { + "name": "resumable", + "description": "Resumable Upload end-points" + }, + { + "name": "cdn", + "description": "CDN cache management" + }, + { + "name": "health", + "description": "Health check end-points" + }, + { + "name": "iceberg", + "description": "Apache Iceberg REST catalog" + }, + { + "name": "vector", + "description": "Vector storage and search" + } + ] +} diff --git a/codegen/specs/storage.upstream.json b/codegen/specs/storage.upstream.json new file mode 100644 index 0000000..9fdf7c1 --- /dev/null +++ b/codegen/specs/storage.upstream.json @@ -0,0 +1 @@ +{"openapi":"3.0.3","info":{"title":"Supabase Storage API","description":"API documentation for Supabase Storage","version":"0.0.0"},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"jwt"}},"schemas":{"def-0":{"type":"object","properties":{"authorization":{"type":"string","example":"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs"}},"required":["authorization"],"title":"authSchema"},"def-1":{"type":"object","properties":{"statusCode":{"type":"string"},"error":{"type":"string"},"message":{"type":"string"}},"required":["statusCode","error","message"],"title":"errorSchema"}}},"paths":{"/upload/resumable/":{"post":{"summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"options":{"summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/{*}":{"post":{"summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"summary":"Handle PUT request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"patch":{"summary":"Handle PATCH request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"Handle HEAD request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"summary":"Handle DELETE request for TUS Resumable uploads","tags":["resumable"],"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"options":{"summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/sign/":{"post":{"summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"responses":{"200":{"description":"Default Response"}}},"options":{"summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","responses":{"200":{"description":"Default Response"}}}},"/upload/resumable/sign/{*}":{"post":{"summary":"Handle POST request for TUS Resumable uploads","tags":["resumable"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"summary":"Handle PUT request for TUS Resumable uploads","tags":["resumable"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"patch":{"summary":"Handle PATCH request for TUS Resumable uploads","tags":["resumable"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"Handle HEAD request for TUS Resumable uploads","tags":["resumable"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"summary":"Handle DELETE request for TUS Resumable uploads","tags":["resumable"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"options":{"summary":"Handle OPTIONS request for TUS Resumable uploads","tags":["resumable"],"description":"Handle OPTIONS request for TUS Resumable uploads","parameters":[{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/bucket/":{"post":{"summary":"Create a bucket","tags":["bucket"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"avatars"},"id":{"type":"string","example":"avatars"},"public":{"type":"boolean","example":false},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"anyOf":[{"type":"integer","examples":[1000],"nullable":true,"minimum":0},{"type":"string","examples":["100MB"],"nullable":true}]},"allowed_mime_types":{"type":"array","nullable":true,"items":{"type":"string"},"example":["image/png","image/jpg"]}},"required":["name"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"name":{"type":"string","example":"avatars"}},"required":["name"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"get":{"summary":"Gets all buckets","tags":["bucket"],"parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"title":"bucketSchema","example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"example":[{"id":"avatars","type":"STANDARD","name":"avatars","owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}]}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Gets all buckets","tags":["bucket"],"parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"example":[{"id":"avatars","type":"STANDARD","name":"avatars","owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}]}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/bucket/{bucketId}/empty":{"post":{"summary":"Empty a bucket","tags":["bucket"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Empty bucket has been queued. Completion may take up to an hour."}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/bucket":{"head":{"summary":"Gets all buckets","tags":["bucket"],"parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"example":[{"id":"avatars","type":"STANDARD","name":"avatars","owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}]}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/bucket/{bucketId}":{"get":{"summary":"Get details of a bucket","tags":["bucket"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false},"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Get details of a bucket","tags":["bucket"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false},"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"put":{"summary":"Update properties of a bucket","tags":["bucket"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"public":{"type":"boolean","example":false},"file_size_limit":{"anyOf":[{"type":"integer","examples":[1000],"nullable":true,"minimum":0},{"type":"string","examples":["100MB"],"nullable":true}]},"allowed_mime_types":{"type":"array","nullable":true,"items":{"type":"string","example":["image/png","image/jpg"]}}},"anyOf":[{"required":["public"]},{"required":["file_size_limit"]},{"required":["allowed_mime_types"]}]}}}},"security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketId","required":true}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully updated"}},"required":["message"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"delete":{"summary":"Delete a bucket","tags":["bucket"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketId","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully deleted"}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/{bucketName}/{*}":{"delete":{"summary":"Delete an object","tags":["object"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully deleted"}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"get":{"summary":"Get object","tags":["object"],"description":"Serve objects","parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"put":{"summary":"Update the object at an existing key","tags":["object"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Retrieve object info","tags":["object"],"description":"Head object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"post":{"summary":"Upload a new object","tags":["object"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/{bucketName}":{"delete":{"summary":"Delete multiple objects","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefixes":{"type":"array","items":{"type":"string"},"minItems":1,"example":["folder/cat.png","folder/morecats.png"]}},"required":["prefixes"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"bucket_id":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"version":{"type":"string"},"id":{"anyOf":[{"type":"string"},{"type":"null"}]},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"last_accessed_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"user_metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"buckets":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"required":["name"],"additionalProperties":false,"title":"objectSchema","example":{"name":"folder/cat.png","bucket_id":"avatars","owner":"317eadce-631a-4429-a0bb-f19a7a517b4a","id":"eaa8bdb5-2e00-4767-b5a9-d2502efe2196","updated_at":"2021-04-06T16:30:35.394674+00:00","created_at":"2021-04-06T16:30:35.394674+00:00","last_accessed_at":"2021-04-06T16:30:35.394674+00:00","metadata":{"size":1234}}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/authenticated/{bucketName}/{*}":{"get":{"summary":"Retrieve an object","tags":["object"],"parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Retrieve object info","tags":["object"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/upload/sign/{bucketName}/{*}":{"post":{"summary":"Generate a presigned url to upload an object","tags":["object"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"url":{"type":"string","example":"/object/sign/upload/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"},"token":{"type":"string"}},"required":["url"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"put":{"summary":"Uploads an object via a presigned URL","tags":["object"],"parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Key":{"type":"string","example":"avatars/folder/cat.png"}},"required":["Key"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/sign/{bucketName}/{*}":{"post":{"summary":"Generate a presigned url to retrieve an object","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"expiresIn":{"type":"integer","minimum":1,"example":60000},"transform":{"type":"object","properties":{"height":{"type":"integer","minimum":0,"example":100},"width":{"type":"integer","minimum":0,"example":100},"resize":{"type":"string","enum":["cover","contain","fill"]},"format":{"type":"string","enum":["origin","avif","webp"]},"quality":{"type":"integer","minimum":20,"maximum":100}}}},"required":["expiresIn"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"signedURL":{"type":"string","example":"/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"}},"required":["signedURL"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"get":{"summary":"Retrieve an object via a presigned URL","tags":["object"],"parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Retrieve an object via a presigned URL","tags":["object"],"parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/sign/{bucketName}":{"post":{"summary":"Generate presigned urls to retrieve objects","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"expiresIn":{"type":"integer","minimum":1,"example":60000},"paths":{"type":"array","items":{"type":"string"},"minItems":1,"example":["folder/cat.png","folder/morecats.png"]}},"required":["expiresIn","paths"]}}}},"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"error":{"type":["null","string"],"example":"Either the object does not exist or you do not have access to it"},"path":{"type":"string","example":"folder/cat.png"},"signedURL":{"type":["null","string"],"example":"/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4"}},"required":["error","path","signedURL"]}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/move":{"post":{"summary":"Moves an object","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"bucketId":{"type":"string","example":"avatars"},"sourceKey":{"type":"string","example":"folder/cat.png"},"destinationBucket":{"type":"string","example":"users"},"destinationKey":{"type":"string","example":"folder/newcat.png"}},"required":["bucketId","sourceKey","destinationKey"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"Successfully moved"}},"required":["message"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/list-v2/{bucketName}":{"post":{"summary":"Search for objects under a prefix","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefix":{"type":"string","example":"folder/subfolder"},"limit":{"type":"integer","minimum":1,"example":10},"cursor":{"type":"string"},"with_delimiter":{"type":"boolean"},"sortBy":{"type":"object","properties":{"column":{"type":"string","enum":["name","updated_at","created_at"]},"order":{"type":"string","enum":["asc","desc"]}},"required":["column"]}}}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/object/list/{bucketName}":{"post":{"summary":"Search for objects under a prefix","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"prefix":{"type":"string","example":"folder/subfolder"},"limit":{"type":"integer","minimum":1,"example":10},"offset":{"type":"integer","minimum":0,"example":0},"sortBy":{"type":"object","properties":{"column":{"type":"string","enum":["name","updated_at","created_at","last_accessed_at"]},"order":{"type":"string","enum":["asc","desc"]}},"required":["column"]},"search":{"type":"string"}},"required":["prefix"]}}}},"parameters":[{"schema":{"type":"string"},"in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"bucket_id":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"version":{"type":"string"},"id":{"anyOf":[{"type":"string"},{"type":"null"}]},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"last_accessed_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"user_metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"buckets":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"required":["name"],"additionalProperties":false,"example":{"name":"folder/cat.png","bucket_id":"avatars","owner":"317eadce-631a-4429-a0bb-f19a7a517b4a","id":"eaa8bdb5-2e00-4767-b5a9-d2502efe2196","updated_at":"2021-04-06T16:30:35.394674+00:00","created_at":"2021-04-06T16:30:35.394674+00:00","last_accessed_at":"2021-04-06T16:30:35.394674+00:00","metadata":{"size":1234}}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/info/authenticated/{bucketName}/{*}":{"get":{"summary":"Retrieve object info","tags":["object"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Retrieve object info","tags":["object"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/info/{bucketName}/{*}":{"get":{"summary":"Retrieve object info","tags":["object"],"description":"Object Info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Retrieve object info","tags":["object"],"description":"Object Info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/copy":{"post":{"summary":"Copies an object","tags":["object"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"bucketId":{"type":"string","example":"avatars"},"sourceKey":{"type":"string","example":"folder/source.png"},"destinationBucket":{"type":"string","example":"users"},"destinationKey":{"type":"string","example":"folder/destination.png"},"metadata":{"type":"object","properties":{"cacheControl":{"type":"string"},"mimetype":{"type":"string"}}},"copyMetadata":{"type":"boolean","example":true}},"required":["sourceKey","bucketId","destinationKey"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"Id":{"type":"string"},"Key":{"type":"string","example":"folder/destination.png"},"name":{"type":"string"},"bucket_id":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"version":{"type":"string"},"id":{"anyOf":[{"type":"string"},{"type":"null"}]},"updated_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"last_accessed_at":{"anyOf":[{"type":"string"},{"type":"null"}]},"metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"user_metadata":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}]},"buckets":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"owner_id":{"type":"string"},"public":{"type":"boolean"},"type":{"type":"string","enum":["STANDARD","ANALYTICS"]},"file_size_limit":{"type":["null","integer"]},"allowed_mime_types":{"type":["null","array"],"items":{"type":"string"}},"created_at":{"type":"string"},"updated_at":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"example":{"id":"bucket2","name":"bucket2","public":false,"file_size_limit":1000000,"allowed_mime_types":["image/png","image/jpeg"],"owner":"4d56e902-f0a0-4662-8448-a4d9e643c142","created_at":"2021-02-17T04:43:32.770206+00:00","updated_at":"2021-02-17T04:43:32.770206+00:00"}}},"required":["Key"]}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/public/{bucketName}/{*}":{"get":{"summary":"Retrieve an object from a public bucket","tags":["object"],"parameters":[{"schema":{"type":"string"},"examples":{"filename.jpg":{"value":"filename.jpg"},"example2":{"value":null}},"in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Get object info","tags":["object"],"description":"returns object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/object/info/public/{bucketName}/{*}":{"get":{"summary":"Get object info","tags":["object"],"description":"returns object info","parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Default Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/render/image/authenticated/{bucketName}/{*}":{"get":{"summary":"Render an authenticated image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Render an authenticated image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/render/image/sign/{bucketName}/{*}":{"get":{"summary":"Render an authenticated image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Render an authenticated image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"string"},"example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJidWNrZXQyL3B1YmxpYy9zYWRjYXQtdXBsb2FkMjMucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.uBQcXzuvXxfw-9WgzWMBfE_nR3VOgpvfZe032sfLSSk","in":"query","name":"token","required":true},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/render/image/public/{bucketName}/{*}":{"get":{"summary":"Render a public image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}},"head":{"summary":"Render a public image with the given transformations","tags":["transformation"],"parameters":[{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"height","required":false},{"schema":{"type":"integer","minimum":0},"example":100,"in":"query","name":"width","required":false},{"schema":{"type":"string","enum":["cover","contain","fill"]},"in":"query","name":"resize","required":false},{"schema":{"type":"string","enum":["origin","avif","webp"]},"in":"query","name":"format","required":false},{"schema":{"type":"integer","minimum":20,"maximum":100},"in":"query","name":"quality","required":false},{"schema":{"type":"string"},"example":"filename.png","in":"query","name":"download","required":false},{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"responses":{"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/s3/{Bucket}/{*}":{"put":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true},{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true},{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"post":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true},{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"delete":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true},{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}},"get":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true},{"schema":{"type":"string"},"in":"path","name":"*","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/s3/{Bucket}":{"delete":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"post":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"get":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/s3/{Bucket}/":{"delete":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"put":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"post":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"get":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}},"head":{"tags":["s3"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"Bucket","required":true}],"responses":{"200":{"description":"Default Response"}}}},"/s3/":{"get":{"tags":["s3"],"responses":{"200":{"description":"Default Response"}}}},"/cdn/{bucketName}/{*}":{"delete":{"summary":"Purge cache for an object","tags":["cdn"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true},{"schema":{"type":"string"},"example":"folder/cat.png","in":"path","name":"*","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"description":"Successful response","type":"object","properties":{"message":{"type":"string","example":"success"}}}}}},"4XX":{"description":"Error response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/def-1"}}}}}}},"/health/":{"get":{"summary":"healthcheck","tags":["health"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"healthcheck","tags":["health"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/health":{"head":{"summary":"healthcheck","tags":["health"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/bucket":{"post":{"summary":"Create an analytics bucket","tags":["bucket"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"avatars"}},"required":["name"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"get":{"summary":"List analytics buckets","tags":["bucket"],"parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"List analytics buckets","tags":["bucket"],"parameters":[{"schema":{"type":"integer","minimum":1},"example":10,"in":"query","name":"limit","required":false},{"schema":{"type":"integer","minimum":0},"example":0,"in":"query","name":"offset","required":false},{"schema":{"type":"string","enum":["id","name","created_at","updated_at"]},"in":"query","name":"sortColumn","required":false},{"schema":{"type":"string","enum":["asc","desc"]},"in":"query","name":"sortOrder","required":false},{"schema":{"type":"string"},"example":"my-bucket","in":"query","name":"search","required":false}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/bucket/{bucketName}":{"delete":{"summary":"Delete an analytics bucket","tags":["bucket"],"parameters":[{"schema":{"type":"string"},"example":"avatars","in":"path","name":"bucketName","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/v1/config":{"get":{"summary":"Get Iceberg catalog configuration","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"my-warehouse","in":"query","name":"warehouse","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"Get Iceberg catalog configuration","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"my-warehouse","in":"query","name":"warehouse","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/v1/{prefix}/namespaces":{"post":{"summary":"Create a namespace","tags":["iceberg"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"namespace":{"type":"string","example":"namespace"},"properties":{"type":"object","additionalProperties":{"type":"string"}}},"required":["namespace"]}}}},"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"get":{"summary":"List namespaces","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"in":"query","name":"pageToken","required":false},{"schema":{"type":"number"},"in":"query","name":"pageSize","required":false},{"schema":{"type":"string"},"in":"query","name":"parent","required":false},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"head":{"summary":"List namespaces","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"in":"query","name":"pageToken","required":false},{"schema":{"type":"number"},"in":"query","name":"pageSize","required":false},{"schema":{"type":"string"},"in":"query","name":"parent","required":false},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/v1/{prefix}/namespaces/{namespace}":{"head":{"summary":"Load a namespace","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"get":{"summary":"Load a namespace","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"delete":{"summary":"Drop a namespace","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/iceberg/v1/{prefix}/namespaces/{namespace}/tables":{"post":{"summary":"Create a table in the given namespace","tags":["iceberg"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","schema"],"properties":{"name":{"type":"string"},"location":{"type":"string","format":"uri","nullable":true},"schema":{"allOf":[{"type":"object","required":["type","fields"],"properties":{"type":{"type":"string","enum":["struct"]},"fields":{"type":"array","items":{"type":"object","required":["id","name","type","required"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"type":{"oneOf":[{"type":"string"},{"type":"object","required":["type","fields"],"properties":{"type":{"type":"string","enum":["struct"]},"fields":{"type":"array","items":{"$comment":"recurse nested StructField definitions here"}}}},{"type":"object","required":["type","element-id","element","element-required"],"properties":{"type":{"type":"string","enum":["list"]},"element-id":{"type":"integer"},"element":{"$comment":"Type object (recurse)"},"element-required":{"type":"boolean"}}},{"type":"object","required":["type","key-id","key","value-id","value","value-required"],"properties":{"type":{"type":"string","enum":["map"]},"key-id":{"type":"integer"},"key":{"$comment":"Type object (recurse)"},"value-id":{"type":"integer"},"value":{"$comment":"Type object (recurse)"},"value-required":{"type":"boolean"}}}]},"required":{"type":"boolean"},"doc":{"type":"string"}}}}}},{"type":"object","properties":{"schema-id":{"type":"integer","readOnly":true},"identifier-field-ids":{"type":"array","items":{"type":"integer"}}}}]},"spec":{"type":"object","required":["fields"],"properties":{"spec-id":{"type":"integer","readOnly":true},"fields":{"type":"array","items":{"type":"object","required":["source-id","transform","name"],"properties":{"field-id":{"type":"integer"},"source-id":{"type":"integer"},"name":{"type":"string"},"transform":{"type":"string"}}}}}},"properties":{"type":"object","additionalProperties":{"type":"string"}},"stage-create":{"type":"boolean","default":false},"write-order":{"type":"object","nullable":true,"required":["fields"],"properties":{"order-id":{"type":"integer","readOnly":true},"fields":{"type":"array","items":{"type":"object","required":["source-id","transform","direction","null-order"],"properties":{"source-id":{"type":"integer"},"transform":{"type":"string"},"direction":{"type":"string","enum":["asc","desc"]},"null-order":{"type":"string","enum":["nulls-first","nulls-last"]}}}}}}}}}}},"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"get":{"summary":"Create a table","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"in":"query","name":"pageToken","required":false},{"schema":{"type":"number"},"in":"query","name":"pageSize","required":false},{"schema":{"type":"string"},"in":"query","name":"parent","required":false},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"head":{"summary":"Create a table","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"in":"query","name":"pageToken","required":false},{"schema":{"type":"number"},"in":"query","name":"pageSize","required":false},{"schema":{"type":"string"},"in":"query","name":"parent","required":false},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/iceberg/v1/{prefix}/namespaces/{namespace}/tables/{table}":{"get":{"summary":"Load an Iceberg Table","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true},{"schema":{"type":"string"},"example":"table","in":"path","name":"table","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"head":{"summary":"Load an Iceberg Table","tags":["iceberg"],"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true},{"schema":{"type":"string"},"example":"table","in":"path","name":"table","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}},"delete":{"summary":"Drop a Table","tags":["iceberg"],"parameters":[{"schema":{"type":"string","enum":["true","false","True","False"],"default":"false"},"in":"query","name":"purgeRequested","required":false,"description":"If true, the table will be permanently deleted"},{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true},{"schema":{"type":"string"},"example":"table","in":"path","name":"table","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}},"post":{"summary":"Commit updates to multiple tables in an atomic operation","tags":["iceberg"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Commit updates to multiple tables in an atomic operation","properties":{"requirements":{"type":"array","description":"Assertions to validate before applying updates","items":{"type":"object","description":"A requirement assertion","required":["type"],"properties":{"type":{"type":"string","description":"Type of the requirement (e.g. assert-ref-snapshot-id, assert-table-uuid)","example":"assert-ref-snapshot-id"},"ref":{"type":"string"},"uuid":{"type":"string"},"args":{"type":"object","additionalProperties":true}},"additionalProperties":true}},"updates":{"type":"array","description":"Metadata updates to apply to the table","items":{"type":"object","description":"A single update operation","required":["action"],"properties":{"action":{"type":"string","description":"Action to perform (e.g. add-snapshot, set-snapshot-ref)","example":"add-snapshot"},"snapshot":{"type":"object","properties":{"sequence-number":{"type":"integer"},"timestamp-ms":{"type":"integer"},"manifest-list":{"type":"string"},"summary":{"type":"object","additionalProperties":true,"properties":{"operation":{"type":"string"},"added-files-size":{"type":"string"},"added-data-files":{"type":"string"},"added-records":{"type":"string"},"total-delete-files":{"type":"string"},"total-records":{"type":"string"},"total-position-deletes":{"type":"string"},"total-equality-deletes":{"type":"string"}}},"schema-id":{"type":"integer"}},"additionalProperties":true},"ref-name":{"type":"string"},"type":{"type":"string"},"args":{"type":"object","additionalProperties":true}},"additionalProperties":true}}},"required":["updates","requirements"]}}},"description":"Commit updates to multiple tables in an atomic operation"},"parameters":[{"schema":{"type":"string"},"example":"prefix","in":"path","name":"prefix","required":true},{"schema":{"type":"string"},"example":"namespace","in":"path","name":"namespace","required":true},{"schema":{"type":"string"},"example":"table","in":"path","name":"table","required":true}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}}}}},"/vector/CreateIndex":{"post":{"summary":"Create a vector index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"dataType":{"type":"string","enum":["float32"]},"dimension":{"type":"number","minimum":1,"maximum":4096},"distanceMetric":{"type":"string","enum":["cosine","euclidean"]},"indexName":{"type":"string","minLength":3,"maxLength":45,"pattern":"^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$","description":"3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket."},"metadataConfiguration":{"type":"object","required":["nonFilterableMetadataKeys"],"properties":{"nonFilterableMetadataKeys":{"type":"array","items":{"type":"string"}}}},"vectorBucketName":{"type":"string"}},"required":["dataType","dimension","distanceMetric","indexName","vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/DeleteIndex":{"post":{"summary":"Delete a vector index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"indexName":{"type":"string","minLength":3,"maxLength":45,"pattern":"^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$","description":"3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket."},"vectorBucketName":{"type":"string"}},"required":["indexName","vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/ListIndexes":{"post":{"summary":"List indexes in a vector bucket","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"},"maxResults":{"type":"number","minimum":1,"maximum":500,"default":500},"nextToken":{"type":"string"},"prefix":{"type":"string"}},"required":["vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/GetIndex":{"post":{"summary":"Get a vector index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"},"indexName":{"type":"string","minLength":3,"maxLength":45,"pattern":"^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$","description":"3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket."}},"required":["vectorBucketName","indexName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/CreateVectorBucket":{"post":{"summary":"Create a vector bucket","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"}},"required":["vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/DeleteVectorBucket":{"post":{"summary":"Create a vector bucket","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"}},"required":["vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/ListVectorBuckets":{"post":{"summary":"List vector buckets","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"maxResults":{"type":"number","minimum":1,"maximum":500,"default":500},"nextToken":{"type":"string"},"prefix":{"type":"string"}}}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/GetVectorBucket":{"post":{"summary":"Create a vector bucket","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"}},"required":["vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/PutVectors":{"post":{"summary":"Put vectors into an index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"},"indexName":{"type":"string","minLength":3,"maxLength":45,"pattern":"^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$","description":"3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket."},"vectors":{"type":"array","minItems":1,"maxItems":500,"items":{"type":"object","properties":{"data":{"type":"object","properties":{"float32":{"type":"array","items":{"type":"number"}}},"required":["float32"]},"metadata":{"type":"object","additionalProperties":{"oneOf":[{"type":"string"},{"type":"boolean"},{"type":"number"}]}},"key":{"type":"string"}},"required":["data"]}}},"required":["vectorBucketName","indexName","vectors"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/QueryVectors":{"post":{"summary":"Query vectors","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"https://schemas.example.com/queryVectorBody.json"}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/DeleteVectors":{"post":{"summary":"Delete vectors from an index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"},"indexName":{"type":"string"},"keys":{"type":"array","items":{"type":"string"}}},"required":["vectorBucketName","indexName","keys"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/ListVectors":{"post":{"summary":"List vectors in a vector index","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"vectorBucketName":{"type":"string"},"indexArn":{"type":"string"},"indexName":{"type":"string","minLength":3,"maxLength":45,"pattern":"^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$","description":"3-63 chars, lowercase letters, numbers, hyphens, dots; must start/end with letter or number. Must be unique within the vector bucket."},"maxResults":{"type":"number","minimum":1,"maximum":500},"nextToken":{"type":"string"},"returnData":{"type":"boolean"},"returnMetadata":{"type":"boolean"},"segmentCount":{"type":"number","minimum":1,"maximum":16},"segmentIndex":{"type":"number","minimum":0,"maximum":15}},"required":["vectorBucketName","indexName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}},"/vector/GetVectors":{"post":{"summary":"Returns vector attributes","tags":["vector"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"indexName":{"type":"string"},"keys":{"type":"array","items":{"type":"string"}},"returnData":{"type":"boolean","default":false},"returnMetadata":{"type":"boolean","default":false},"vectorBucketName":{"type":"string"}},"required":["indexName","keys","vectorBucketName"]}}}},"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Default Response"}}}}},"tags":[{"name":"object","description":"Object end-points"},{"name":"bucket","description":"Bucket end-points"},{"name":"s3","description":"S3 end-points"},{"name":"transformation","description":"Image transformation"},{"name":"resumable","description":"Resumable Upload end-points"},{"name":"cdn","description":"CDN cache management"},{"name":"health","description":"Health check end-points"},{"name":"iceberg","description":"Apache Iceberg REST catalog"},{"name":"vector","description":"Vector storage and search"}]} \ No newline at end of file diff --git a/codegen/upstream-spec-issues.md b/codegen/upstream-spec-issues.md new file mode 100644 index 0000000..7931799 --- /dev/null +++ b/codegen/upstream-spec-issues.md @@ -0,0 +1,39 @@ +# Upstream OpenAPI spec issues + +Problems in upstream Supabase OpenAPI specs that the codegen **normalizer** (`scripts/capability-matrix/src/normalize.ts`) currently works around. Each is a band-aid applied downstream; fixing it in the source repo lets us delete the corresponding transform and shrinks the gap between "what the service publishes" and "what generates clean SDKs." + +Most of these affect **every** SDK language, not just Swift — missing `operationId`s and `def-N` schema names produce unstable, ugly symbols in any generator, and the OAS 3.1-in-a-3.0-doc issues break any strict 3.0 generator. So upstream fixes pay off across the whole matrix. + +When an item is fixed upstream: bump the spec pin in `codegen.yaml`, re-run `npm run generate:check`, and remove the now-unnecessary transform (and its test). + +--- + +## Storage — `supabase/storage` + +- Published spec: `https://supabase.github.io/storage/api.json` (generated by the service's `@fastify/swagger`). +- Pinned for codegen: `gh-pages@53e6a743d5b02e7e7e7b7549f7490517773be016`. +- The document declares `openapi: 3.0.3` but contains several OpenAPI **3.1** constructs — the single root cause behind items 4–6. A holistic upstream fix would be to either emit valid 3.0 or declare 3.1 honestly. + +| # | Issue | Where (count) | Codegen impact | Normalizer workaround | Upstream fix | +|---|-------|---------------|----------------|----------------------|--------------| +| 1 | No `operationId`s | every operation (0 / 108) | Generators synthesize method names from method+path → unstable, ugly, churn on path edits. Our `binding.operationId` must reference SDK-injected ids. | `injectOperationIds` + curated overrides in `codegen/normalize/storage.json` | Author `operationId` on every route (Fastify route `schema.operationId`, or `@fastify/swagger` `transform`). **Highest leverage.** | +| 2 | Fastify `{*}` wildcard path params | 15 paths, param literally named `*` | Invalid OpenAPI; openapi-generator emits unnamed Swift params → ~2,269 compile errors. | `renameWildcardParams` → `{objectPath}` | Use a named wildcard/path param (e.g. `objectPath`) in the route + swagger schema. | +| 3 | Placeholder schema names `def-0`, `def-1` | 2 schemas (`def-0`=`authSchema` orphan/0 refs; `def-1`=`errorSchema`, 40 refs) | Generated model types are named `Def0`/`Def1` instead of meaningful names. | `renameSchemas` → `AuthHeader`, `ErrorBody` | Register shared schemas with stable named keys (`$id`/named refs) so the published `$ref` keys are human-readable. | +| 4 | `type: ["null", T]` nullable arrays (OAS 3.1) | 18 occurrences | Invalid in OAS 3.0; rejected by 3.0 generators. | `fixArrayTypes` → `nullable: true, type: T` | Emit OAS 3.0 `nullable: true` (or commit to a 3.1 document + 3.1 generator). | +| 5 | `$comment` in schema objects (OAS 3.1) | 4 occurrences (Iceberg schemas) | Not valid on 3.0 schema objects. | `stripDollarComments` | Remove, or move content into `description`. | +| 6 | Plural `examples` arrays in schema objects (OAS 3.1) | 4 occurrences | 3.0 schema objects allow only singular `example`. (Parameter/Media-Type `examples` maps are valid in 3.0 and are preserved.) | `stripSchemaExamples` (scoped to schema objects) | Use singular `example` on schema objects in 3.0. | +| 7 | External `$ref` to a placeholder URL | 1 (`https://schemas.example.com/queryVectorBody.json`, the `POST /vector/QueryVectors` request body) | Unresolvable external ref to an `example.com` placeholder; generation can't see the body shape. | `inlineExternalRefs` → permissive `{ type: object }` — **lossy: the request body's real fields/typing are discarded.** | Define the QueryVectors request body inline (or as a proper local component schema) with its real fields. **Most impactful data-loss workaround — fixing this restores a real typed body.** | +| 8 | Case-insensitive property collision `Id` vs `id` | 1 (`POST /object/copy` 200 response: `Id` non-nullable + `id` nullable) | Case-sensitive-name generators (Swift) emit "invalid redeclaration of 'id'". | `dedupCaseInsensitiveProperties` — **lossy: drops `Id`, keeps the nullable `id`.** | Expose a single canonical property (decide `id` vs `Id`, nullable or not) in the copy response. | +| 9 | `file_size_limit` `oneOf: [integer, string]` | 1 (`CreateBucketRequest`) | openapi-generator collapses a primitive `oneOf` to an empty struct (`CreateBucketRequestFileSizeLimit` has no stored property → can't carry a value); source of the 2 build warnings. | **None** — the generated type is empty and `internal`; the hand-written surface (Plan 3) must wrap/replace it. | Model as a single type — e.g. a `string` with documented units (`"100MB"`) or an `integer` byte count — instead of a primitive `oneOf`. | +| 10 | No `requestBody` on binary upload/download endpoints | Whole file-I/O family: `POST`/`PUT /object/{bucketName}/{*}`, `/object/upload/sign/*`, all `/upload/resumable/*` (TUS), all `/s3/*` — only **26 of 108** operations declare any body | Generated methods take only path params, so there is **no parameter to carry file bytes**: `uploadObject(bucketName:objectPath:)` has no `Data`/body and its `parameters` is `nil`. The generated core cannot upload or download object content at all. | `injectRequestBodies` (interim) — injects an `application/octet-stream` body (`schema: { type: string, format: binary }`) for the two plain object-upload ops: `POST /object/{bucketName}/{objectPath}` and `PUT /object/{bucketName}/{objectPath}` via `codegen/normalize/storage.json`. The resumable/TUS (`/upload/resumable/*`) and S3 (`/s3/*`) endpoints still lack bodies and need the upstream fix (different protocols, not safe to inject blindly). | Add a `requestBody` to these ops — `application/octet-stream` (`schema: { type: string, format: binary }`) for raw uploads, `multipart/form-data` where form uploads are accepted. **The most important Storage gap for a usable client.** | + +### Notes +- Items 1–3 were found in the codegen spike (`docs/plans/2026-06-16-codegen-swift-storage-spike.md`); items 4–9 surfaced while making the generated Swift compile (Plan 2, Task 6); item 10 was found while reviewing the generated `uploadObject` method. +- **Item 10 is the top usability blocker** — without request bodies on the file endpoints, the generated client can't move object bytes. Item 1 (naming, all SDKs) and items 7 & 8 (the two **lossy** workarounds) follow. +- Item 9 has no normalizer workaround; it is carried into the Plan 3 hand-written surface. Item 10 now has an interim normalizer workaround for the two plain object-upload ops (`injectRequestBodies`); the resumable/TUS and S3 endpoints still require the upstream fix or a Plan 3 hand-written surface. + +--- + +## Auth, Functions (not yet onboarded) + +Add sections here as each spec is bound for codegen. Apply the same pattern: discover via a spike, work around in the normalizer only where necessary, and record the upstream fix so the workaround can later be removed. diff --git a/docs/design/2026-06-16-sdk-code-generation-design.md b/docs/design/2026-06-16-sdk-code-generation-design.md new file mode 100644 index 0000000..dd1597b --- /dev/null +++ b/docs/design/2026-06-16-sdk-code-generation-design.md @@ -0,0 +1,205 @@ +# Supabase SDK code generation — design + +- Status: draft for review +- Date: 2026-06-16 +- Owner: Guilherme Souza (Swift SDK owner) +- Repo: `supabase/sdk` + +## 1. Problem and goal + +Supabase ships seven client SDKs (`javascript`, `flutter`, `python`, `swift`, `csharp`, `go`, `kotlin`). A large fraction of every SDK is the same work re-done by hand in a different language: HTTP transport, request signing and headers, serialization, request/response models, and error-code tables. This layer drifts between SDKs, is tedious to maintain, and is exactly the kind of thing a machine should produce. + +Two new hires are about to build the C# and Go SDKs from scratch. This is the moment to agree on a shared definition of code generation so each engineer spends their time on what makes their stack unique — idiomatic ergonomics — and not on plumbing that is identical everywhere. + +**Goal:** generate the mechanical, drift-prone layer of each SDK from a shared contract, leaving each engineer to hand-write only the idiomatic public surface. + +## 2. Goals and non-goals + +**Goals** +- Generate transport, request/response models, and error types from a machine-readable contract. +- Use one generation engine across all languages, with per-language output each owner controls. +- Make some generated code (models, error types) public API, not just internal plumbing. +- Keep hand-written surfaces honest across languages with a shared conformance suite. +- Validate the whole approach on one real SDK before scaling it. + +**Non-goals (for this milestone)** +- Generating the full public API surface method-for-method. +- Generating PostgREST or Realtime clients (see section 10). +- End-user code generation from a project's database schema (e.g. `supabase gen types`). This design is about SDK-internal generation, not user-facing generation. +- Building a custom generator engine. + +## 3. Summary of decisions + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Generated/hand-written boundary | Generate transport + types + errors; hand-write ergonomics | +| 2 | Source of truth | Upstream OpenAPI specs (auth/storage/functions); this repo is the index | +| 3 | Generation engine | One shared engine (`openapi-generator`), per-language template packs | +| 4 | Template location | Central in `supabase/sdk` under `templates//`, maintained by SDK owners | +| 5 | Consumption | Generated code committed per SDK repo; regenerated via `make generate`; CI drift guard | +| 6 | Visibility | Boundary is machine-owned vs hand-edited — some generated code is public API | +| 7 | Rollout | Swift first (owner builds the reference), then C#/Go greenfield, then the rest | +| 8 | Pilot product | Storage | +| 9 | Pilot strategy (Swift) | Parallel module beside the shipped one; conformance parity gates cutover | + +## 4. Architecture: the pipeline + +```mermaid +flowchart TD + subgraph Upstream["Upstream OpenAPI specs (owned by service teams)"] + A[Auth spec] + S[Storage spec] + F[Functions spec] + end + subgraph Central["supabase/sdk — contract hub"] + IDX["Capability matrix as index
feature id to spec + operationId"] + CFG["codegen.yaml
pinned engine + spec pins"] + TPL["templates/<lang>
maintained by SDK owners"] + CONF["conformance vectors"] + end + GEN[["openapi-generator engine"]] + subgraph SDKRepo["each SDK repo (committed)"] + CORE["generated/ core
internal transport · public models · public errors"] + SURF["hand-written public surface
builders · auth state · ergonomics"] + end + A --> IDX + S --> IDX + F --> IDX + IDX --> GEN + CFG --> GEN + TPL --> GEN + GEN --> CORE + CORE --> SURF + CONF -. verifies .-> SURF +``` + +Reading it top to bottom: upstream specs stay the source of truth and stay owned by the service teams; this repo becomes the index that binds each feature to a spec operation and pins the generator and templates; one engine emits a per-language core that is committed into each SDK repo; the engineer hand-writes the public surface on top; a shared conformance suite verifies the surfaces behave identically. + +## 5. The generated/hand-written boundary + +The line is **machine-owned vs hand-edited**, not hidden vs public. The generated core is never hand-edited — it is regenerated — but parts of it are public API, re-exported as-is. Generated code splits by visibility: + +- `generated/internal/` — transport, serialization, request signing. Never public. +- `generated/models/` — request/response types. **Public, re-exported as-is.** The hand-written surface returns these types; it does not define parallel hand-written DTOs. +- `generated/errors/` — error codes and types. **Public, as-is**, so error handling matches the server contract and is identical across SDKs. + +The hand-written surface adds only ergonomics — builders, async patterns, auth/session state, language idioms. It stops short of re-modeling data the server already defines. + +**Consequence:** making generated types public couples each SDK's public API to the upstream spec. Spec bumps therefore become release-planning events, and the drift guard must classify regeneration diffs as public (a semver event) vs internal (a patch). The upside is that no one hand-maintains types the server already defines. + +## 6. Source of truth and the index + +Upstream per-service OpenAPI specs are canonical for the generated layer. Auth, Storage, and Functions already publish OpenAPI specs owned by their service teams. We point at those; we do not re-describe them. + +This repo becomes the **index**. Additive changes only — today's prose-only entries keep validating: + +- Each `capabilities/*.yaml` feature gains an optional `binding: { spec, operationId }`. +- A new `codegen.yaml` holds the pinned `openapi-generator` version, the spec source list with version pins, and per-language template-pack refs. +- The existing TypeScript tooling in `scripts/capability-matrix/` gains a `bindings` validator and a `make generate` CLI wrapper. + +The matrix's existing aggregate/parity job extends to assert that every feature an SDK declares `implemented` has a binding and is covered by generated code — so parity stops being a manual honor system. + +### 6.1 Spec normalization (added after the Storage spike) + +The Storage spike (`docs/plans/2026-06-16-codegen-swift-storage-spike.md`) found that upstream specs are not always generation-ready. The Storage OpenAPI (3.0.3, 108 operations) has **no `operationId`s at all**, uses Fastify `{*}` wildcard path params (invalid OpenAPI that produces non-compiling Swift), and has placeholder schema names (`def-0`/`def-1`). So the pipeline gains a **normalization step** between the upstream spec and the generator: a committed, deterministic transform that injects `operationId`s, renames non-standard path params, and renames schemas, emitting a committed normalized spec that the generator consumes. + +Design consequence: a feature's `binding.operationId` references the **normalized** spec's id (which may be SDK-injected), not necessarily an upstream-native one. Both the pinned upstream spec and the normalized spec are committed, so generation is deterministic and offline, and normalization diffs are reviewable. + +## 7. Repo layout and ownership + +The contract is central and singular; the output is per-SDK and owned. + +**`supabase/sdk` — the contract hub:** + +``` +capabilities/*.yaml # gains optional binding: { spec, operationId } +codegen.yaml # NEW: pinned engine version, spec pins, template-pack refs +templates// # NEW: per-language template packs (CODEOWNERS -> SDK owner) +conformance/ # NEW: language-agnostic test vectors (input -> expected behavior) +scripts/capability-matrix/ # existing TS tooling, extended with bindings validator + make generate +``` + +Templates live centrally so packaging conventions (what is public vs internal) are reviewable in one place, but each `templates//` directory is maintained by its SDK owner via `CODEOWNERS`. + +**Each SDK repo — the owned output:** + +``` +Makefile -> make generate # one command; pulls specs + config + templates from a pinned central version +.codegen-version # pins which supabase/sdk version this SDK generates against +generated/ # committed, machine-owned, do-not-edit core +src/ (or equivalent) # hand-written public surface +``` + +**Ownership in one line:** central owns the contract and the conformance bar; each engineer owns their templates, their generated output, and their entire public surface. + +## 8. Consumption: generation, committing, drift, semver + +- **Committed, not built on the fly.** `generated/` lands in version control. Contributors and CI never need the generator toolchain to build or test the SDK, diffs are reviewable, and it plugs into each language's normal packaging. +- **`make generate` is the glue.** It pulls the pinned engine version, spec pins, and the `(feature -> operation)` index from a pinned version of `supabase/sdk`, runs `openapi-generator` with the SDK's central template pack, and writes `generated/`. Deterministic, because specs are version-pinned, not "latest." +- **Drift guard.** CI runs `make generate` and fails if it produces a diff. When a spec pin bumps in `codegen.yaml`, central CI opens regeneration PRs against each SDK repo. +- **Semver classification.** A regeneration diff is classified as public (a semver event — breaking or additive) vs internal (a patch). Public diffs are release-planning events reviewed by the SDK owner. + +## 9. Conformance suite + +A language-agnostic set of test vectors (input -> expected behavior) lives in `conformance/`. Each SDK wires the vectors into its own test suite. This keeps "idiomatic" from drifting into "incompatible," and — for any migration — doubles as the equivalence proof between an old hand-written module and a new generated-backed one. + +## 10. PostgREST and Realtime (out of generated scope) + +These do not fit OpenAPI and are out of scope for the generated pipeline: + +- **PostgREST** is schema-dynamic — there is no fixed endpoint surface; it reflects the user's database. The client is a query/filter builder, not a set of generated operations. +- **Realtime** is a WebSocket protocol, not request/response REST. + +Both keep purpose-built, hand-written cores. When addressed, they reuse the central-config and conformance patterns from this design but get their own design docs. + +## 11. Pilot: Swift + Storage, parallel module + +The owner builds the pilot, which lays the central rails (`codegen.yaml`, bindings, the `make generate` wrapper, the conformance harness) and the first template pack (`templates/swift/`). That pack becomes the reference the C#/Go hires mirror. + +Storage is the pilot product: bounded but representative — real models, real error cases, and real transport edge cases (binary upload/download, multipart) that genuinely exercise the generated core. + +Because supabase-swift is shipped, the pilot builds a **parallel Storage target** (working name `StorageGen`) beside the existing `Storage`, backed by the generated core plus a hand-written surface. The shipped module is untouched. Cutover is a later step with its own semver decision, out of pilot scope. + +**Deliverables in `supabase/sdk`:** +- `binding: { spec, operationId }` added to Storage features in `capabilities/storage.yaml` +- `codegen.yaml` with the storage spec pin, pinned engine version, and a `swift` template ref +- `templates/swift/` starter pack tuned to emit public models/errors + internal transport +- `conformance/storage/` vectors +- TS tooling extended with the `bindings` validator and the `make generate` wrapper + +**Deliverables in supabase-swift:** +- `make generate` produces `generated/` for storage +- A `StorageGen` target built on the generated core, exposing generated models/errors publicly +- Conformance vectors wired in and passing +- Behavior parity demonstrated between `StorageGen` and the existing `Storage` + +**Success criteria:** +1. `StorageGen` is generated core + hand-written surface only — no hand-written transport, types, or error tables. +2. The conformance suite passes on `StorageGen` and matches the existing `Storage`'s behavior. +3. A simulated spec bump regenerates cleanly with a reviewable diff and a correct public-vs-internal classification. +4. A written comparison: generated-vs-hand-written ratio, ergonomics delta, and any spec gaps found — the baseline for projecting across products and a go/no-go input for cutover. + +## 12. Rollout after the pilot + +1. **Swift pilot** (this design) — owner builds reference + central rails. +2. **C# and Go, greenfield** — hires mirror the Swift reference, building straight on the generated core (no parallel module needed). +3. **Back-port** the generated core into the remaining existing SDKs (JS/Python/Kotlin/Flutter), product by product. +4. **PostgREST and Realtime** — purpose-built cores, their own designs. + +Binding coverage expands across products (auth, functions) in parallel as each is needed. + +## 13. Risks and open questions + +- **`openapi-generator` Swift output quality.** Its default Swift models may not match supabase-swift's modern Codable/async conventions. Mitigation: this is exactly the template-pack work, and doing it first as the reference is the point. Validate early in the pilot. +- **Upstream spec quality.** Specs vary in completeness and accuracy across service teams. The pilot will surface gaps in the Storage spec; fixes flow upstream. +- **Public-API/spec coupling.** Addressed by the semver classification in section 8; the parallel-module pilot lets us judge generated public types before committing the real API to them. +- **Central template repo as a bottleneck.** Mitigated by `CODEOWNERS` per directory; revisit if review latency becomes a problem. +- **Open: generated-package naming/visibility conventions per language** (e.g. Swift module boundaries, Go `internal/` packages, C# `internal` access). To be fixed by the reference template pack and documented for the hires. + +## 14. Glossary + +- **Generated core** — the machine-owned, regenerated layer: internal transport + public models + public errors. +- **Public surface** — the hand-written, idiomatic client built on top of the core. +- **Index** — this repo's binding from a feature id to an upstream OpenAPI `(spec, operationId)`. +- **Conformance vectors** — language-agnostic input/expected-behavior cases all SDKs must satisfy. +- **Drift guard** — CI check that regeneration produces no diff against committed `generated/`. diff --git a/docs/design/2026-06-16-swift-codegen-runtime-design.md b/docs/design/2026-06-16-swift-codegen-runtime-design.md new file mode 100644 index 0000000..48379a2 --- /dev/null +++ b/docs/design/2026-06-16-swift-codegen-runtime-design.md @@ -0,0 +1,193 @@ +# Swift codegen: lean templates over a hand-written runtime — design + +- Status: draft for review +- Date: 2026-06-16 +- Owner: Guilherme Souza (Swift SDK owner) +- Repo: `supabase/sdk` +- Builds on: the Swift Storage pilot (`docs/plans/2026-06-16-codegen-swift-storage-pilot.md`) and the generated-code audit findings. + +## 1. Problem and goal + +The Storage pilot generates a Swift package with `openapi-generator`'s stock `swift6` templates. An audit of the output found the *data* layer (models) is fine, but the *transport* layer is not the best Swift/Apple-platform experience: file bodies are buffered fully into memory (no streaming), networking goes through a `withCheckedThrowingContinuation` bridge over `dataTask(completionHandler:)` instead of native async, a global mutable `Configuration.shared` singleton is injected into every call, concurrency uses `@unchecked Sendable` + `NSRecursiveLock`, and there is no progress reporting, no background-session support, and no streaming-response support. These are the things a hand-crafted Swift SDK does well. + +**Goal:** make the generated Swift SDK modern and idiomatic for Swift 6+, with **simpler** templates, by generating only the data layer and hand-writing the transport as a small, reusable runtime. + +## 2. Goals and non-goals + +**Goals** +- Generate only models + a thin, typed client; hand-write the transport. +- A generic, reusable runtime usable by Storage now and Auth/Functions later. +- First-class streaming upload/download, **upload/download progress**, **background sessions**, and **streaming responses** (event streams / SSE). +- Native Swift 6 concurrency (actors, `Sendable`, async/await), injected configuration (no singleton), typed errors. +- Keep the shared engine (`openapi-generator`) and the existing pipeline (normalizer, `codegen.yaml`, binding, drift guard) driving the data layer — so Swift stays a mirrorable reference for C#/Go. + +**Non-goals (this design)** +- Switching Swift to a different generator (e.g. Apple's `swift-openapi-generator`) — considered and declined to preserve the one-shared-engine pillar and Swift's role as the cross-SDK reference. +- The normalizer naming/dedup batch from the audit (operationIds, response-schema names, shared `$ref`s, `date-time`, int-not-double, HEAD/trailing-slash dedup) — it improves the *names/types* the generator emits but is independent of this work; separate effort. +- The hand-written ergonomic surface layer — a later, optional layer on top of the generated client. + +## 3. Decisions + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Vehicle for modern Swift | Minimal templates + hand-written Swift 6 runtime (not full Mustache rewrite, not a different generator) | +| 2 | Generated ↔ runtime contract | Thin generated methods over an injected `Transport` protocol | +| 3 | Runtime reusability | Generic, reusable package (`SupabaseRuntime`); product specifics injected via config | +| 4 | Transfer capabilities | Streaming I/O + progress + background sessions + streaming responses, all in the runtime | +| 5 | Configuration | Injected `ClientConfiguration` (no global singleton); async `AuthProvider` | +| 6 | Scope | This design = the runtime package (sub-project A) + the lean template pack targeting it (sub-project B) → two implementation plans | + +## 4. Architecture + +```mermaid +flowchart TD + subgraph gen["Generated (openapi-generator + lean templates)"] + SC["StorageClient — thin typed op methods"] + M["Models — Codable · public · Date"] + end + T["Transport protocol
send · upload · download · stream"] + subgraph rt["Hand-written runtime — SupabaseRuntime (generic, reusable)"] + UST["URLSessionTransport (actor)
async URLSession · streaming · progress · background"] + CC["ClientConfiguration
baseURL · auth · codecs · errorMapper · sessionKind"] + end + SURF["Hand-written surface (later) — ergonomic sugar"] + SURF --> SC + SC --> M + SC --> T + UST -. conforms .-> T + CC --> UST +``` + +Only the data layer (models + a thin `StorageClient`) is generated. The `Transport` protocol is the entire seam between machine-owned and hand-written. The hand-written `SupabaseRuntime` package carries every idiomatic, stateful, lifecycle-aware concern. The generator's ~700-line transport machinery is deleted from the template set. + +## 5. The generated ↔ runtime contract + +The seam is one value type plus a small protocol, all in `SupabaseRuntime`. Generated code depends on nothing else. + +```swift +public enum HTTPMethod: String, Sendable { case get, post, put, delete, patch, head } + +public struct HTTPRequest: Sendable { + public var method: HTTPMethod + public var path: String // percent-encoded via RequestPath interpolation + public var query: [URLQueryItem] = [] + public var headers: [String: String] = [:] +} + +public struct HTTPResponseHead: Sendable { public let status: Int; public let headers: [String: String] } +public enum UploadSource: Sendable { case file(URL); case data(Data) } + +public struct TransferProgress: Sendable { + public let completed: Int64 + public let total: Int64? // nil when length unknown + public var fraction: Double? // completed/total when known +} + +// Returned synchronously by upload/download; carries live progress + the awaitable result. +public struct TransferTask: Sendable { + public let progress: AsyncStream // finishes when the transfer ends + public func value() async throws -> Value // awaits the final (decoded) result + public func cancel() +} + +// Streamed response body for event streams / SSE / incremental reads. +public struct ResponseStream: Sendable { + public let head: HTTPResponseHead + public let body: AsyncThrowingStream, any Error> +} + +public protocol Transport: Sendable { + func send(_ request: HTTPRequest) async throws -> R // GET/DELETE + func send(_ request: HTTPRequest, body: B) async throws -> R // JSON body + func send(_ request: HTTPRequest) async throws // no-content + func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask + func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask + func stream(_ request: HTTPRequest) async throws -> ResponseStream +} +``` + +Generated op methods are one-liners; the response type is inferred, so the template just picks the overload that matches the operation's content type: + +```swift +public struct StorageClient: Sendable { + let transport: any Transport + public init(transport: any Transport) { self.transport = transport } +} + +extension StorageClient { + public func getBucket(bucketId: String) async throws -> Bucket { + try await transport.send(HTTPRequest(method: .get, path: "/bucket/\(param: bucketId)")) + } + public func createBucket(_ body: CreateBucketRequest) async throws -> CreateBucketResponse { + try await transport.send(HTTPRequest(method: .post, path: "/bucket/"), body: body) + } + public func uploadObject(bucketName: String, objectPath: String, + from source: UploadSource) -> TransferTask { + transport.upload(HTTPRequest(method: .post, path: "/object/\(param: bucketName)/\(param: objectPath)"), from: source) + } +} +``` + +- **Path params** percent-encode through a `RequestPath` string interpolation (`\(param:)`) the runtime provides, so the template emits a plain interpolated string and correctness lives in the runtime. +- **Query params** map from optional typed args to `[URLQueryItem]`, skipping `nil` — a small template loop. +- **Errors are typed.** On non-2xx the runtime runs `ClientConfiguration.errorMapper(data, head)` and throws its result (e.g. a `StorageError` decoded from `ErrorBody`); absent a mapper it throws `TransportError.http(status:data:head:)`. Callers `catch` a real error, not raw `Data`. +- **Template → transport-method mapping by content type:** JSON request/response → `send`; binary upload → `upload`; file/stream download → `download`/`stream`. + +## 6. The runtime package — `SupabaseRuntime` + +Generic, reusable, hand-written, in `codegen/runtime/swift/SupabaseRuntime/`. Small single-responsibility files, each unit-tested: + +| File | Responsibility | +|------|----------------| +| `Transport.swift` | `Transport` protocol + `HTTPRequest`/`HTTPMethod`/`HTTPResponseHead`/`UploadSource` | +| `Streaming.swift` | `TransferProgress`, `TransferTask`, `ResponseStream` | +| `ClientConfiguration.swift` | base URL, default headers, JSON coders, `errorMapper`, `sessionKind` | +| `AuthProvider.swift` | async header supplier (`@Sendable () async throws -> [String: String]`) — fits token refresh | +| `URLSessionTransport.swift` | the `actor` implementing `Transport`: native `URLSession.data(for:)`, streaming `uploadTask(fromFile:)`/`downloadTask`, `bytes(for:)`-based `stream`, background config, cancellation | +| `SessionDelegate.swift` | `NSObject` URLSession delegate bridging callbacks → progress streams / continuations | +| `TransportError.swift` | typed errors: `http(status,data,head)`, `transport(Error)`, `decoding(Error)`, `cancelled` | +| `RequestPath.swift` | `\(param:)` percent-encoding string interpolation | +| `MockTransport.swift` | in-memory `Transport` so generated clients and the surface test with zero network | + +**Concurrency model:** `URLSessionTransport` is an `actor`; progress comes from `URLSessionTaskDelegate.didSendBodyData` (upload) and `URLSessionDownloadDelegate.didWriteData` (download) feeding the `TransferTask.progress` continuation; `cancel()` bridges Swift `Task` cancellation to `URLSessionTask.cancel()`. No `@unchecked Sendable`, no manual locks. + +**Background sessions:** selected via `ClientConfiguration.sessionKind = .background(identifier:)`, building the transport on `URLSessionConfiguration.background`. Two constraints are intrinsic and baked into the design: +1. Background transfers are **file-based only** (`UploadSource.file` / `download(toFile:)`); in-memory bodies are rejected for background. +2. They need an **app-relaunch hook**: the runtime exposes `handleBackgroundEvents(identifier:completionHandler:)` for the app to call from `handleEventsForBackgroundURLSession` / SwiftUI `backgroundTask`, so the transport resumes pending `value()` continuations and flushes final progress after the system relaunches the app. + +**Streaming responses:** `stream(_:)` returns the response head + an `AsyncThrowingStream` of byte chunks (from `URLSession.bytes(for:)`); the hand-written surface parses domain frames (e.g. an SSE parser) on top. The transport stays content-agnostic. + +**Testing:** pure types tested directly; `URLSessionTransport` tested against a stubbed protocol (a `URLProtocol` subclass) so no real network is needed; `MockTransport` lets downstream layers test without URLSession at all. + +## 7. Template + `codegen.yaml` changes + +- **New `templates/swift/` pack:** + - `api.mustache` → emit a single `StorageClient` (struct, `public init(transport:)`, per-tag `extension`s) of thin op methods over `Transport`, replacing the static-func API classes. + - `Package.mustache` → generated `Package.swift` depends on the local `SupabaseRuntime` package and `import`s it. + - Model templates stay close to stock; light touch only if required (e.g. enforce `Sendable`). +- **Suppress the stock infrastructure** with `.openapi-generator-ignore`: `URLSessionImplementations`, `RequestBuilder`, `Configuration`, `JSONEncodingHelper`, `OpenISO8601DateFormatter`, `SynchronizedDictionary`, `OpenAPIMutex`, `APIHelper`, `CodableHelper`, `JSONDataEncoding`, `Extensions`, `APIs.swift`, `Validation` — all replaced by `SupabaseRuntime`. +- **`codegen.yaml` swift target:** add `templates: templates/swift`; set `generatorProperties.nonPublicApi: "false"` and `enumUnknownDefaultCase: "true"`; keep `projectName`. (The `library`/`responseAs` properties stop mattering once the stock transport is suppressed, but stay harmless.) + +## 8. Scope, packaging, and decomposition + +- **In-repo layout:** `codegen/runtime/swift/SupabaseRuntime/` (committed, `swift test`-covered) and `codegen/generated/swift-storage/` regenerated to depend on it via a local SwiftPM path. `swift build` and the existing `generate:check` drift guard both apply. +- **Two implementation plans:** + - **Plan A — `SupabaseRuntime`:** the runtime package, hand-written and TDD, buildable and testable with zero generation. It is the foundation and stands alone. + - **Plan B — lean templates + regenerate:** the `templates/swift` pack + `codegen.yaml` changes; regenerate Storage onto the runtime; `swift build` clean; drift-guarded. +- **Separate follow-ups (not in this spec):** the normalizer naming/dedup batch (audit), and the hand-written ergonomic surface. + +## 9. Risks and open questions + +- **Background-session semantics are genuinely complex** (app relaunch, persisted task→continuation mapping, file-only). The design contains them behind the `sessionKind` config + `handleBackgroundEvents` hook; the first Plan A milestone should prove a foreground transfer with progress before background is wired. +- **openapi-generator template-override mechanics** — suppressing supporting files via `.openapi-generator-ignore` while overriding `api`/`Package` templates needs validation (a short spike at the start of Plan B), since the generator still expects a `library`. +- **`send` overload resolution** — the no-body vs body vs no-content overloads must resolve unambiguously from generated call sites; verify with the real operation set. +- **`stream` for Storage** — Storage's object GET is binary; whether it maps to `download(toFile:)`, a buffered `send -> Data`, or `stream` is a per-op modeling choice the template makes by response content type; confirm during Plan B. +- **Reuse vs the existing supabase-swift networking** — when this is extracted to `supabase-swift`, `SupabaseRuntime` should reconcile with whatever shared networking exists there; out of scope in-repo, but keep the package boundary clean to ease that. + +## 10. Glossary + +- **Transport** — the protocol seam between generated code and the runtime (`send`/`upload`/`download`/`stream`). +- **`SupabaseRuntime`** — the generic, reusable, hand-written Swift package implementing `Transport` over URLSession. +- **`TransferTask`** — a handle for an in-flight upload/download exposing live `progress` and an awaitable `value()`. +- **`ResponseStream`** — a streamed response (head + byte chunks) for event streams / SSE. +- **Generated client** — the thin `StorageClient` of typed op methods the generator emits over `Transport`. diff --git a/docs/plans/2026-06-16-codegen-swift-storage-pilot.md b/docs/plans/2026-06-16-codegen-swift-storage-pilot.md new file mode 100644 index 0000000..7532e59 --- /dev/null +++ b/docs/plans/2026-06-16-codegen-swift-storage-pilot.md @@ -0,0 +1,896 @@ +# Swift Storage codegen pilot (in-repo) implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prove the codegen pipeline end-to-end **inside `supabase/sdk`**: normalize the upstream Storage OpenAPI spec, generate a zero-dependency Swift package from it with `openapi-generator`, commit the result, and have `swift build` pass — all reproducibly, with a drift guard and validated feature bindings. + +**Architecture:** A committed, deterministic normalizer turns the pinned upstream Storage spec into a generation-ready normalized spec (injects `operationId`s, fixes Fastify `{*}` path params, renames `def-N` schemas). `codegen.yaml` declares the engine pin, the normalized spec, and a Swift target. A thin runner spawns `openapi-generator` (stock `swift6` generator + `nonPublicApi` config — no custom templates yet) to emit a committed Swift package under `codegen/generated/swift-storage/`. The validator gains an operationId cross-check so bindings can't reference operations that don't exist. The hand-written surface + conformance execution are deferred to Plan 3. + +**Tech Stack:** TypeScript (ESM, `tsx`, `vitest`) for the normalizer/runner/validation — extends `scripts/capability-matrix`. `openapi-generator` 7.23.0 (on PATH at `/opt/homebrew/bin/openapi-generator`, needs Java — present). Swift 6.3.2 for `swift build`. All confirmed available in this environment. + +**Depends on:** Plan 1 (committed) — `binding` field, `codegen.yaml` schema/loader, `checkBindings`, `buildGenerateArgs`, conformance format, and the `run()` wiring. + +**Grounding facts (from `docs/plans/2026-06-16-codegen-swift-storage-spike.md`):** +- Pinned upstream spec: `https://raw.githubusercontent.com/supabase/storage/53e6a743d5b02e7e7e7b7549f7490517773be016/api.json` (OpenAPI 3.0.3, 52 paths, 108 operations). +- **0 of 108 operations have `operationId`.** +- **15 paths contain `{*}`** as their last segment, each with a path parameter literally named `*`. Uniform shape → one rule fixes all. +- Two component schemas only: `def-0` (`title: authSchema`, 0 `$ref`s, orphan) and `def-1` (`title: errorSchema`: `statusCode`/`error`/`message`, 40 `$ref`s). +- Generator: `swift6` with `responseAs=AsyncAwait, library=urlsession, useSPMFileStructure=true` emits zero external deps; `Models/` compile cleanly; `nonPublicApi=true` makes APIs/Infrastructure internal. + +**Conventions (from existing code):** ESM imports with **no file extension**; checks return `Finding[]`; schema validation via `compileSchema()`; YAML/JSON via `yaml`'s `parse`/`JSON`; tests via `vitest` with `mkdtempSync`/`tmpdir`. Run a single test: `cd scripts/capability-matrix && npx vitest run test/.test.ts`. Typecheck: `npm run typecheck`. + +**File map (new/changed):** +``` +codegen.yaml # NEW (root): engine pin + storage spec + swift target +codegen/normalize/storage.json # NEW: storage normalize config (schema renames + operationId overrides) +codegen/specs/storage.upstream.json # NEW: committed pinned fetch (provenance + offline source) +codegen/specs/storage.normalized.json # NEW: committed normalizer output (generation input) +codegen/generated/swift-storage/ # NEW: committed generated Swift package (Package.swift + Sources/) +scripts/capability-matrix/src/normalize.ts # NEW: pure normalizer transforms +scripts/capability-matrix/src/normalize-cli.ts # NEW: CLI: upstream.json + config -> normalized.json +scripts/capability-matrix/src/generate.ts # MODIFY: add runGenerate(); make --template-dir optional +scripts/capability-matrix/src/generate-cli.ts # NEW: CLI: run openapi-generator for codegen.yaml targets +scripts/capability-matrix/src/codegen.ts # MODIFY: optional templates + optional targets +scripts/capability-matrix/src/bindings.ts # MODIFY: add checkBindingOperations() +scripts/capability-matrix/src/cli.ts # MODIFY: wire operationId cross-check +schema/codegen.schema.json # MODIFY: templates optional + targets array +scripts/capability-matrix/package.json # MODIFY: add normalize / generate / generate:check scripts +capabilities/storage.yaml # MODIFY: add bindings to a pilot subset of features +.gitignore # MODIFY: ignore the generated package's .build/ +``` + +--- + +### Task 1: Make `templates` optional and add `targets` to the codegen config + +**Files:** +- Modify: `schema/codegen.schema.json` +- Modify: `scripts/capability-matrix/src/codegen.ts` +- Modify: `scripts/capability-matrix/src/generate.ts` (the `buildGenerateArgs` template handling) +- Test: `scripts/capability-matrix/test/codegen.test.ts`, `scripts/capability-matrix/test/generate.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `scripts/capability-matrix/test/codegen.test.ts` (inside the existing `describe("checkCodegenConfig", ...)`): + +```ts + it("accepts a language without templates (stock generator)", () => { + const cfg = { ...valid, languages: { swift: { generator: "swift6" } } }; + expect(checkCodegenConfig(cfg, schema)).toEqual([]); + }); + + it("accepts an optional targets array", () => { + const cfg = { ...valid, targets: [{ spec: "storage", language: "swift", output: "codegen/generated/swift-storage" }] }; + expect(checkCodegenConfig(cfg, schema)).toEqual([]); + }); + + it("rejects a target missing output", () => { + const cfg = { ...valid, targets: [{ spec: "storage", language: "swift" }] }; + expect(checkCodegenConfig(cfg, schema).length).toBeGreaterThan(0); + }); +``` + +Append to `scripts/capability-matrix/test/generate.test.ts` (inside the existing `describe("buildGenerateArgs", ...)`): + +```ts + it("omits --template-dir when the language has no templates", () => { + const stock: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.23.0" }, + specs: { storage: { source: "codegen/specs/storage.normalized.json", version: "v1" } }, + languages: { swift: { generator: "swift6" } }, + }; + const args = buildGenerateArgs(stock, { spec: "storage", language: "swift", outDir: "out" }); + expect(args.includes("--template-dir")).toBe(false); + expect(args).toEqual([ + "generate", + "--input-spec", "codegen/specs/storage.normalized.json", + "--generator-name", "swift6", + "--output", "out", + ]); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd scripts/capability-matrix && npx vitest run test/codegen.test.ts test/generate.test.ts` +Expected: FAIL — the schema currently requires `templates`, has no `targets`, and `buildGenerateArgs` always emits `--template-dir`. (The "no templates" config currently fails schema validation, and `buildGenerateArgs` would throw or mis-emit.) + +- [ ] **Step 3: Update the schema** + +In `schema/codegen.schema.json`: under `languages`'s `additionalProperties`, change `"required": ["generator", "templates"]` to `"required": ["generator"]` (leave the `templates` and `generatorProperties` property definitions as-is — they remain optional). Then add a top-level optional `targets` property (sibling of `engine`/`specs`/`languages`): + +```json + "targets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["spec", "language", "output"], + "properties": { + "spec": { "type": "string", "minLength": 1 }, + "language": { "type": "string", "minLength": 1 }, + "output": { "type": "string", "minLength": 1 } + } + } + } +``` + +- [ ] **Step 4: Update the types** + +In `scripts/capability-matrix/src/codegen.ts`: make `templates` optional on `LanguageConfig` and add the target type + optional `targets`: + +```ts +export interface LanguageConfig { + generator: string; + templates?: string; + generatorProperties?: Record; +} + +export interface GenerateTargetConfig { + spec: string; + language: string; + output: string; +} + +export interface CodegenConfig { + engine: { tool: string; version: string }; + specs: Record; + languages: Record; + targets?: GenerateTargetConfig[]; +} +``` + +- [ ] **Step 5: Make `buildGenerateArgs` emit `--template-dir` only when present** + +In `scripts/capability-matrix/src/generate.ts`, replace the unconditional template-dir push. The arg array should be built as: + +```ts + const args = [ + "generate", + "--input-spec", spec.source, + "--generator-name", lang.generator, + "--output", target.outDir, + ]; + if (lang.templates) { + args.push("--template-dir", lang.templates); + } +``` + +(Keep the existing `generatorProperties` handling exactly as-is, after this block.) + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cd scripts/capability-matrix && npx vitest run test/codegen.test.ts test/generate.test.ts` +Expected: PASS. + +- [ ] **Step 7: Typecheck, full suite, commit** + +```bash +cd scripts/capability-matrix && npm run typecheck && npm test +``` +Expected: clean typecheck, full suite green. + +```bash +git add schema/codegen.schema.json scripts/capability-matrix/src/codegen.ts scripts/capability-matrix/src/generate.ts scripts/capability-matrix/test/codegen.test.ts scripts/capability-matrix/test/generate.test.ts +git commit -m "feat: make codegen templates optional and add generate targets" +``` + +--- + +### Task 2: Spec normalizer transforms + +**Files:** +- Create: `scripts/capability-matrix/src/normalize.ts` +- Test: `scripts/capability-matrix/test/normalize.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/normalize.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { renameWildcardParams, renameSchemas, deriveOperationId, injectOperationIds, normalizeSpec } from "../src/normalize"; + +describe("renameWildcardParams", () => { + it("renames {*} path keys and their `*` path params", () => { + const spec: any = { + paths: { + "/cdn/{bucketName}/{*}": { + delete: { parameters: [ + { in: "path", name: "bucketName", required: true, schema: { type: "string" } }, + { in: "path", name: "*", required: true, schema: { type: "string" } }, + ] }, + }, + }, + }; + renameWildcardParams(spec, "objectPath"); + expect(spec.paths["/cdn/{bucketName}/{*}"]).toBeUndefined(); + const op = spec.paths["/cdn/{bucketName}/{objectPath}"].delete; + expect(op.parameters.find((p: any) => p.in === "path" && p.name === "objectPath")).toBeTruthy(); + expect(op.parameters.find((p: any) => p.name === "*")).toBeUndefined(); + }); +}); + +describe("renameSchemas", () => { + it("renames component schema keys and rewrites $refs", () => { + const spec: any = { + components: { schemas: { "def-1": { type: "object" } } }, + paths: { "/x": { get: { responses: { "4XX": { content: { "application/json": { schema: { $ref: "#/components/schemas/def-1" } } } } } } } }, + }; + renameSchemas(spec, { "def-1": "ErrorBody" }); + expect(spec.components.schemas["def-1"]).toBeUndefined(); + expect(spec.components.schemas["ErrorBody"]).toBeTruthy(); + expect(spec.paths["/x"].get.responses["4XX"].content["application/json"].schema.$ref).toBe("#/components/schemas/ErrorBody"); + }); +}); + +describe("deriveOperationId", () => { + it("derives a deterministic camelCase id", () => { + expect(deriveOperationId("head", "/bucket")).toBe("headBucket"); + expect(deriveOperationId("get", "/bucket/{bucketId}")).toBe("getBucketByBucketId"); + }); +}); + +describe("injectOperationIds", () => { + it("injects derived ids, honors overrides, and keeps them unique", () => { + const spec: any = { + paths: { + "/bucket": { get: {}, post: {} }, + "/object/{bucketName}/{objectPath}": { post: {} }, + }, + }; + injectOperationIds(spec, { "POST /object/{bucketName}/{objectPath}": "uploadObject" }); + expect(spec.paths["/bucket"].get.operationId).toBe("getBucket"); + expect(spec.paths["/bucket"].post.operationId).toBe("postBucket"); + expect(spec.paths["/object/{bucketName}/{objectPath}"].post.operationId).toBe("uploadObject"); + }); + + it("does not overwrite an existing operationId", () => { + const spec: any = { paths: { "/x": { get: { operationId: "keepMe" } } } }; + injectOperationIds(spec, {}); + expect(spec.paths["/x"].get.operationId).toBe("keepMe"); + }); +}); + +describe("normalizeSpec", () => { + it("applies wildcard rename, schema rename, then operationId injection (override keyed on normalized path)", () => { + const spec: any = { + components: { schemas: { "def-1": { type: "object" } } }, + paths: { + "/object/{bucketName}/{*}": { + post: { parameters: [ + { in: "path", name: "bucketName", required: true, schema: { type: "string" } }, + { in: "path", name: "*", required: true, schema: { type: "string" } }, + ], responses: { "4XX": { content: { "application/json": { schema: { $ref: "#/components/schemas/def-1" } } } } } }, + }, + }, + }; + normalizeSpec(spec, { + schemaRenames: { "def-1": "ErrorBody" }, + operationIdOverrides: { "POST /object/{bucketName}/{objectPath}": "uploadObject" }, + }); + const op = spec.paths["/object/{bucketName}/{objectPath}"].post; + expect(op.operationId).toBe("uploadObject"); + expect(op.parameters.some((p: any) => p.name === "objectPath")).toBe(true); + expect(spec.components.schemas["ErrorBody"]).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/normalize.test.ts` +Expected: FAIL — `../src/normalize` does not exist. + +- [ ] **Step 3: Implement the normalizer** + +Create `scripts/capability-matrix/src/normalize.ts`: + +```ts +// Normalizes an upstream OpenAPI document in place so it generates clean, +// compilable client code. The document is loosely typed (OpenAPI is huge); +// we operate on the few shapes we care about. +type OpenApiDoc = Record; + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options"] as const; + +export interface NormalizeOptions { + wildcardParamName?: string; + schemaRenames?: Record; + operationIdOverrides?: Record; +} + +function camelCase(input: string): string { + const words = input.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + return words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join(""); +} + +/** Replaces `{*}` path segments (Fastify wildcards) and their `*`-named path params. */ +export function renameWildcardParams(spec: OpenApiDoc, paramName = "objectPath"): OpenApiDoc { + const paths = spec.paths ?? {}; + for (const key of Object.keys(paths)) { + if (!key.includes("{*}")) continue; + const newKey = key.split("{*}").join(`{${paramName}}`); + const item = paths[key]; + const paramArrays = [item.parameters, ...HTTP_METHODS.map((m) => item[m]?.parameters)]; + for (const params of paramArrays) { + if (!Array.isArray(params)) continue; + for (const p of params) { + if (p && p.in === "path" && p.name === "*") p.name = paramName; + } + } + delete paths[key]; + paths[newKey] = item; + } + return spec; +} + +/** Renames component schemas and rewrites every `$ref` that pointed at them. */ +export function renameSchemas(spec: OpenApiDoc, renames: Record): OpenApiDoc { + const schemas = spec.components?.schemas ?? {}; + const refMap = new Map(); + for (const [oldName, newName] of Object.entries(renames)) { + if (schemas[oldName] === undefined) continue; + schemas[newName] = schemas[oldName]; + delete schemas[oldName]; + refMap.set(`#/components/schemas/${oldName}`, `#/components/schemas/${newName}`); + } + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if (typeof node.$ref === "string" && refMap.has(node.$ref)) node.$ref = refMap.get(node.$ref)!; + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** Deterministic, unique-ish id from method + path (params rendered as `by `). */ +export function deriveOperationId(method: string, path: string): string { + const parts = path.split("/").filter(Boolean).map((seg) => { + const m = seg.match(/^\{(.+)\}$/); + return m ? `by ${m[1]}` : seg; + }); + return camelCase([method, ...parts].join(" ")); +} + +/** Sets `operationId` on every operation lacking one (override map wins; guarantees uniqueness). */ +export function injectOperationIds(spec: OpenApiDoc, overrides: Record = {}): OpenApiDoc { + const used = new Set(); + const paths = spec.paths ?? {}; + // First pass: record ids that already exist so derived ones don't collide. + for (const path of Object.keys(paths)) { + for (const m of HTTP_METHODS) { + const op = paths[path]?.[m]; + if (op?.operationId) used.add(op.operationId); + } + } + for (const path of Object.keys(paths)) { + for (const m of HTTP_METHODS) { + const op = paths[path]?.[m]; + if (!op || op.operationId) continue; + const base = overrides[`${m.toUpperCase()} ${path}`] ?? deriveOperationId(m, path); + let id = base; + let n = 2; + while (used.has(id)) id = `${base}${n++}`; + op.operationId = id; + used.add(id); + } + } + return spec; +} + +/** Full normalization: wildcard params, then schema renames, then operationId injection. */ +export function normalizeSpec(spec: OpenApiDoc, options: NormalizeOptions = {}): OpenApiDoc { + renameWildcardParams(spec, options.wildcardParamName ?? "objectPath"); + if (options.schemaRenames) renameSchemas(spec, options.schemaRenames); + injectOperationIds(spec, options.operationIdOverrides ?? {}); + return spec; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/normalize.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck, full suite, commit** + +```bash +cd scripts/capability-matrix && npm run typecheck && npm test +git add scripts/capability-matrix/src/normalize.ts scripts/capability-matrix/test/normalize.test.ts +git commit -m "feat: add OpenAPI spec normalizer transforms" +``` + +--- + +### Task 3: Normalizer CLI + fetch and commit the Storage specs + +**Files:** +- Create: `scripts/capability-matrix/src/normalize-cli.ts` +- Create: `codegen/normalize/storage.json` +- Create (fetched/generated): `codegen/specs/storage.upstream.json`, `codegen/specs/storage.normalized.json` +- Modify: `scripts/capability-matrix/package.json` (add `normalize` script) + +- [ ] **Step 1: Write the normalize config** + +Create `codegen/normalize/storage.json`: + +```json +{ + "schemaRenames": { + "def-0": "AuthHeader", + "def-1": "ErrorBody" + }, + "operationIdOverrides": { + "POST /object/{bucketName}/{objectPath}": "uploadObject", + "GET /bucket": "listBuckets", + "POST /bucket": "createBucket" + } +} +``` + +(These three overrides are confirmed-safe starting points. You will extend this map in Task 8 once you can read the full operationId list from the normalized spec.) + +- [ ] **Step 2: Write the CLI** + +Create `scripts/capability-matrix/src/normalize-cli.ts`: + +```ts +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { normalizeSpec, type NormalizeOptions } from "./normalize"; + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function main(): void { + const root = repoRoot(); + // Usage: tsx src/normalize-cli.ts + // Defaults target the Storage pilot. + const argv = process.argv.slice(2); + const input = resolve(root, argv[0] ?? "codegen/specs/storage.upstream.json"); + const output = resolve(root, argv[1] ?? "codegen/specs/storage.normalized.json"); + const configPath = resolve(root, argv[2] ?? "codegen/normalize/storage.json"); + + const spec = JSON.parse(readFileSync(input, "utf8")); + const options = JSON.parse(readFileSync(configPath, "utf8")) as NormalizeOptions; + normalizeSpec(spec, options); + writeFileSync(output, JSON.stringify(spec, null, 2) + "\n"); + console.log(`normalized ${input} -> ${output}`); +} + +main(); +``` + +- [ ] **Step 3: Add the npm script** + +In `scripts/capability-matrix/package.json` `scripts`, add: +```json + "normalize": "tsx src/normalize-cli.ts", +``` + +- [ ] **Step 4: Fetch the pinned upstream spec and produce the normalized spec** + +From the repo root: +```bash +mkdir -p codegen/specs +curl -sS --fail "https://raw.githubusercontent.com/supabase/storage/53e6a743d5b02e7e7e7b7549f7490517773be016/api.json" -o codegen/specs/storage.upstream.json +cd scripts/capability-matrix && npm run normalize +``` +Expected: `codegen/specs/storage.normalized.json` is written. + +- [ ] **Step 5: Verify the normalization actually fixed the spec** + +From the repo root, confirm the three issues are resolved (these should all print `0`, then a positive count): +```bash +echo "remaining {*} paths: $(grep -c '{\*}' codegen/specs/storage.normalized.json)" +echo "remaining def- schemas: $(grep -c '"def-' codegen/specs/storage.normalized.json)" +node -e "const s=require('./codegen/specs/storage.normalized.json');const ids=Object.values(s.paths).flatMap(p=>Object.entries(p).filter(([k])=>['get','put','post','delete','patch','head','options'].includes(k)).map(([,o])=>o.operationId));console.log('operations:',ids.length,'withId:',ids.filter(Boolean).length,'unique:',new Set(ids).size)" +``` +Expected: `remaining {*} paths: 0`, `remaining def- schemas: 0`, and `operations`, `withId`, and `unique` are all equal (108, 108, 108). + +- [ ] **Step 6: Commit** + +```bash +git add codegen/normalize/storage.json codegen/specs/storage.upstream.json codegen/specs/storage.normalized.json scripts/capability-matrix/src/normalize-cli.ts scripts/capability-matrix/package.json +git commit -m "feat: normalize and commit the Storage OpenAPI spec" +``` + +--- + +### Task 4: Author `codegen.yaml` + +**Files:** +- Create: `codegen.yaml` (repo root) + +- [ ] **Step 1: Write the config** + +Create `codegen.yaml` at the repo root: + +```yaml +# yaml-language-server: $schema=./schema/codegen.schema.json +engine: + tool: openapi-generator + version: "7.23.0" + +specs: + storage: + source: codegen/specs/storage.normalized.json + version: "gh-pages@53e6a743d5b02e7e7e7b7549f7490517773be016" + +languages: + swift: + generator: swift6 + generatorProperties: + projectName: SupabaseStorage + responseAs: AsyncAwait + library: urlsession + useSPMFileStructure: "true" + nonPublicApi: "true" + +targets: + - spec: storage + language: swift + output: codegen/generated/swift-storage +``` + +- [ ] **Step 2: Verify it validates** + +From the repo root: +```bash +cd scripts/capability-matrix && npm run validate +``` +Expected: `OK — capability matrix is valid.` (The codegen branch now runs because `codegen.yaml` exists; it must pass `checkCodegenConfig`. There are no feature bindings yet, so `checkBindings` is a no-op.) + +- [ ] **Step 3: Commit** + +```bash +git add codegen.yaml +git commit -m "feat: add codegen.yaml for the Swift Storage pilot" +``` + +--- + +### Task 5: Generation runner + CLI + +**Files:** +- Modify: `scripts/capability-matrix/src/generate.ts` (add `runGenerate`) +- Create: `scripts/capability-matrix/src/generate-cli.ts` +- Modify: `scripts/capability-matrix/package.json` (add `generate` script) + +- [ ] **Step 1: Add `runGenerate` to `src/generate.ts`** + +Append to `scripts/capability-matrix/src/generate.ts`: + +```ts +import { spawnSync } from "node:child_process"; + +export interface RunGenerateOptions { + cwd: string; + bin?: string; + stdio?: "inherit" | "pipe"; +} + +/** Spawns openapi-generator with the args from buildGenerateArgs. Throws on non-zero exit. */ +export function runGenerate(config: CodegenConfig, target: GenerateTarget, opts: RunGenerateOptions): void { + const args = buildGenerateArgs(config, target); + const bin = opts.bin ?? "openapi-generator"; + const res = spawnSync(bin, args, { cwd: opts.cwd, stdio: opts.stdio ?? "inherit" }); + if (res.error) throw new Error(`failed to spawn ${bin}: ${res.error.message}`); + if (res.status !== 0) throw new Error(`${bin} ${args.join(" ")} exited with status ${res.status}`); +} +``` + +(Keep the existing `buildGenerateArgs` and `GenerateTarget` exports. The `import { spawnSync }` line goes with the other imports at the top of the file.) + +- [ ] **Step 2: Write the CLI** + +Create `scripts/capability-matrix/src/generate-cli.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { loadCodegenConfig } from "./codegen"; +import { runGenerate } from "./generate"; + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function main(): void { + const root = repoRoot(); + const { config, findings } = loadCodegenConfig(join(root, "codegen.yaml")); + if (!config) { + for (const f of findings) console.error(`ERROR ${f.file}: ${f.message}`); + process.exit(1); + } + const targets = config.targets ?? []; + if (targets.length === 0) { + console.log("no targets declared in codegen.yaml"); + return; + } + for (const t of targets) { + console.log(`generating ${t.spec} -> ${t.language} into ${t.output}`); + // Paths in codegen.yaml are repo-root relative; run with cwd=root so they resolve. + runGenerate(config, { spec: t.spec, language: t.language, outDir: t.output }, { cwd: root }); + } +} + +main(); +``` + +- [ ] **Step 3: Add the npm script** + +In `scripts/capability-matrix/package.json` `scripts`, add: +```json + "generate": "tsx src/generate-cli.ts", +``` + +- [ ] **Step 4: Typecheck and commit (no generation run yet)** + +```bash +cd scripts/capability-matrix && npm run typecheck && npm test +git add scripts/capability-matrix/src/generate.ts scripts/capability-matrix/src/generate-cli.ts scripts/capability-matrix/package.json +git commit -m "feat: add openapi-generator runner and generate CLI" +``` + +--- + +### Task 6: Generate the Swift package and verify it builds + +**Files:** +- Create (generated, committed): `codegen/generated/swift-storage/` +- Modify: `.gitignore` + +- [ ] **Step 1: Ignore the Swift build directory** + +Add to `.gitignore` (create the file if it does not exist; check first): +``` +codegen/generated/**/.build/ +``` + +- [ ] **Step 2: Generate** + +From the repo root: +```bash +cd scripts/capability-matrix && npm run generate +``` +Expected: `openapi-generator` runs and writes `codegen/generated/swift-storage/` containing `Package.swift` and `Sources/SupabaseStorage/{Models,APIs,Infrastructure}/`. No errors. + +- [ ] **Step 3: Build the generated package** + +From the repo root: +```bash +cd codegen/generated/swift-storage && swift build 2>&1 | tail -20 +``` +Expected: `Build complete!` (the normalized spec removed the `{*}` wildcards that previously caused ~2,269 compile errors). If the build fails, capture the errors and STOP — report as BLOCKED with the specific errors; do not commit a non-building package. + +- [ ] **Step 4: Commit the generated package** + +```bash +git add .gitignore codegen/generated/swift-storage +git commit -m "feat: generate and commit the Swift Storage core (builds clean)" +``` + +--- + +### Task 7: Drift guard + +**Files:** +- Modify: `scripts/capability-matrix/package.json` (add `generate:check`) + +- [ ] **Step 1: Add the drift-check script** + +In `scripts/capability-matrix/package.json` `scripts`, add (the `git -C ../..` targets the repo root from this package dir): +```json + "generate:check": "npm run normalize && npm run generate && git -C ../.. diff --exit-code -- codegen/specs/storage.normalized.json codegen/generated/swift-storage", +``` + +- [ ] **Step 2: Verify regeneration is deterministic (no diff)** + +From the repo root: +```bash +cd scripts/capability-matrix && npm run generate:check +``` +Expected: exits 0 with no diff — regenerating the normalized spec and the Swift package reproduces exactly what is committed. If there is a diff, the generation is non-deterministic; STOP and report the diff (a common cause is an unpinned input or a timestamp in generated output — if openapi-generator writes a timestamp, add that file to `.openapi-generator-ignore` in the output and regenerate before committing). + +- [ ] **Step 3: Commit** + +```bash +git add scripts/capability-matrix/package.json +git commit -m "feat: add generate:check drift guard" +``` + +--- + +### Task 8: Cross-validate binding operationIds and bind the pilot subset + +**Files:** +- Modify: `scripts/capability-matrix/src/bindings.ts` (add `checkBindingOperations`) +- Modify: `scripts/capability-matrix/src/cli.ts` (wire it in) +- Modify: `codegen/normalize/storage.json` (extend overrides for the bound subset) and regenerate +- Modify: `capabilities/storage.yaml` (add bindings) +- Test: `scripts/capability-matrix/test/bindings.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `scripts/capability-matrix/test/bindings.test.ts`: + +```ts +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkBindingOperations } from "../src/bindings"; + +describe("checkBindingOperations", () => { + function specDir(operationIds: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "spec-")); + const paths: any = {}; + operationIds.forEach((id, i) => { paths[`/op${i}`] = { get: { operationId: id } }; }); + writeFileSync(join(dir, "storage.normalized.json"), JSON.stringify({ openapi: "3.0.3", paths })); + return dir; + } + const cfg: any = { + engine: { tool: "openapi-generator", version: "7.23.0" }, + specs: { storage: { source: "storage.normalized.json", version: "v1" } }, + languages: { swift: { generator: "swift6" } }, + }; + function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; + } + + it("passes when a binding's operationId exists in the spec", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.upload", name: "U", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }]); + expect(checkBindingOperations([a], cfg, base)).toEqual([]); + }); + + it("errors when the operationId is not in the spec", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.ghost", name: "G", description: "d", binding: { spec: "storage", operationId: "ghostOp" } }]); + const findings = checkBindingOperations([a], cfg, base); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("ghostOp"); + }); + + it("ignores features without a binding", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.none", name: "N", description: "d" }]); + expect(checkBindingOperations([a], cfg, base)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/bindings.test.ts` +Expected: FAIL — `checkBindingOperations` does not exist. + +- [ ] **Step 3: Implement `checkBindingOperations`** + +Append to `scripts/capability-matrix/src/bindings.ts` (the existing `import type` lines stay; add `readFileSync` + `path` imports): + +```ts +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options"]; + +function specOperationIds(file: string): Set { + const doc = JSON.parse(readFileSync(file, "utf8")) as Record; + const ids = new Set(); + for (const item of Object.values(doc.paths ?? {})) { + for (const m of HTTP_METHODS) { + const op = (item as any)?.[m]; + if (op?.operationId) ids.add(op.operationId); + } + } + return ids; +} + +/** + * Verifies each feature binding's operationId exists in its referenced spec. + * `baseDir` is the directory codegen.yaml lives in (spec.source is relative to it). + */ +export function checkBindingOperations(loaded: LoadedArea[], config: CodegenConfig, baseDir: string): Finding[] { + const findings: Finding[] = []; + const cache = new Map>(); + for (const { file, area } of loaded) { + for (const feature of area?.features ?? []) { + const binding = feature.binding; + if (!binding) continue; + const spec = config.specs[binding.spec]; + if (!spec) continue; // unknown spec already reported by checkBindings + const specFile = resolve(baseDir, spec.source); + let ids = cache.get(specFile); + if (!ids) { + try { + ids = specOperationIds(specFile); + } catch (e) { + findings.push({ level: "error", file, message: `cannot read spec "${spec.source}" for operationId check: ${(e as Error).message}` }); + ids = new Set(); + } + cache.set(specFile, ids); + } + if (!ids.has(binding.operationId)) { + findings.push({ level: "error", file, message: `feature "${feature.id}" binds to operationId "${binding.operationId}" not present in spec "${binding.spec}"` }); + } + } + } + return findings; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/bindings.test.ts` +Expected: PASS. + +- [ ] **Step 5: Wire the cross-check into `run()`** + +In `scripts/capability-matrix/src/cli.ts`: import `checkBindingOperations` from `./bindings`, import `dirname` from `node:path` (add to the existing `node:path` import), and inside the codegen branch — immediately after `findings.push(...checkBindings(areas, config))` — add: + +```ts + findings.push(...checkBindingOperations(areas, config, dirname(opts.codegenConfigPath))); +``` + +- [ ] **Step 6: Choose the pilot subset and finalize operationIds** + +List the available operationIds and pick ~5 storage features to bind: +```bash +node -e "const s=require('./codegen/specs/storage.normalized.json');for(const [p,item] of Object.entries(s.paths))for(const m of ['get','put','post','delete','patch','head','options'])if(item[m])console.log(item[m].operationId,' <-',m.toUpperCase(),p)" | sort +``` +Pick 5 features that exist in `capabilities/storage.yaml` and map cleanly to an operation (e.g. create file bucket, list buckets, upload object, plus two more you can confidently identify from the path list). For any whose derived id is ugly, add a readable override to `codegen/normalize/storage.json` `operationIdOverrides` (keyed `"METHOD /normalized/path"`). If you change overrides, regenerate: `cd scripts/capability-matrix && npm run generate:check` must still pass after re-committing the regenerated specs/package (run `npm run normalize && npm run generate`, then re-commit `codegen/`). + +- [ ] **Step 7: Add bindings to `capabilities/storage.yaml`** + +For each chosen feature, add a `binding` (keep existing `id`/`name`/`description`/`group`). Example shape (use the real operationIds from Step 6): + +```yaml + - id: storage.file_buckets.create_file_bucket + name: Create File Bucket + description: Create a new file storage bucket. + group: file_buckets + binding: + spec: storage + operationId: createBucket +``` + +- [ ] **Step 8: Validate** + +```bash +cd scripts/capability-matrix && npm run validate +``` +Expected: `OK — capability matrix is valid.` — every binding references the `storage` spec (declared in `codegen.yaml`) and an operationId that exists in the normalized spec. To prove the cross-check bites, temporarily change one binding's `operationId` to `nope`, run validate (expect an error naming `nope`), then revert. + +- [ ] **Step 9: Full suite, typecheck, commit** + +```bash +cd scripts/capability-matrix && npm test && npm run typecheck +git add scripts/capability-matrix/src/bindings.ts scripts/capability-matrix/src/cli.ts scripts/capability-matrix/test/bindings.test.ts capabilities/storage.yaml codegen/normalize/storage.json codegen/specs/storage.normalized.json codegen/generated/swift-storage +git commit -m "feat: cross-check binding operationIds and bind the Storage pilot subset" +``` + +--- + +## Out of scope (Plan 3) + +- Hand-written `StorageClient` surface over the generated core (the public ergonomics layer). +- Conformance vectors executed as Swift tests proving behavior. +- A CI workflow running `generate:check` + `swift build` on PRs (this plan adds the npm scripts; wiring them into `.github/workflows` is Plan 3). +- Custom `templates/swift/` overrides (stock `swift6` + `nonPublicApi` is the Plan 2 baseline; revisit only if ergonomics demand it). +- Moving the pilot out to the supabase-swift repo (deferred until the in-repo slice is finalized). + +## Self-review notes + +- **Spec coverage:** normalizer (design §6.1) → Tasks 2, 3; deterministic committed specs → Task 3; codegen.yaml + stock-template generation (§6/§8) → Tasks 1, 4, 5; committed generated code that builds (§5/§11) → Task 6; drift guard (§8) → Task 7; binding→operation enforcement / "no honor system" (§6) → Task 8. Hand-written surface + conformance (§9/§11) explicitly deferred to Plan 3. +- **Type consistency:** `LanguageConfig.templates?`, `GenerateTargetConfig {spec,language,output}`, `CodegenConfig.targets?`, `runGenerate(config, target, {cwd,bin,stdio})`, `checkBindingOperations(loaded, config, baseDir)`, and the normalizer exports (`renameWildcardParams`, `renameSchemas`, `deriveOperationId`, `injectOperationIds`, `normalizeSpec`) are used identically across tasks and tests. +- **No placeholders:** authored code (Tasks 1, 2, 8) is complete; generation/build/drift steps (Tasks 3–7) are exact commands with expected output. The only deliberately deferred specifics are the final pilot operationId picks (Task 8 Step 6), which depend on reading the generated operationId list and are bounded by an explicit procedure. +- **Determinism risk:** Task 7 is the guard; if openapi-generator emits a timestamp/version stamp, Task 7 Step 2 documents the `.openapi-generator-ignore` fix. diff --git a/docs/plans/2026-06-16-codegen-swift-storage-spike.md b/docs/plans/2026-06-16-codegen-swift-storage-spike.md new file mode 100644 index 0000000..7bc9d5a --- /dev/null +++ b/docs/plans/2026-06-16-codegen-swift-storage-spike.md @@ -0,0 +1,264 @@ +# Codegen Spike: Swift Storage SDK via openapi-generator + +**Date:** 2026-06-16 +**Spike type:** Discovery / read-only — no generated code committed + +--- + +## 1. Tooling + +| Item | Value | +|------|-------| +| `openapi-generator version` | **7.23.0** | +| Available Swift generators | `swift6`, `swift-combine` | +| Selected generator | **`swift6`** (most modern; `swift-combine` is also available but Combine-focused) | +| Java version | 25 (required runtime; present at `/opt/homebrew/bin/openapi-generator`) | +| Swift version | 6.3.2 | + +### Key `--additional-properties` for `swift6` + +| Property | Default | Notes | +|----------|---------|-------| +| `projectName` | — | Sets the Swift module name and SPM target name | +| `useSPMFileStructure` | `true` | Emits `Sources//` layout (use this) | +| `responseAs` | — | Set to `AsyncAwait` for async/await methods | +| `library` | `urlsession` | Also: `alamofire`, `vapor`; `urlsession` has zero external deps | +| `nonPublicApi` | `false` | Set `true` to reduce visibility for embedding | +| `hashableModels` | `true` | Models conform to `Hashable` | +| `identifiableModels` | `true` | Models conform to `Identifiable` when `id` present | +| `useClasses` | `false` | Uses structs by default (good for Swift value semantics) | +| `enumUnknownDefaultCase` | `false` | Set `true` for forward-compat with new server enum values | +| `additionalModelObjectAttributes` | — | Inject Swift attributes (e.g. `@MainActor`) into model declarations | +| `additionalModelImports` | — | Add `import` lines to every model file | +| `swiftPackagePath` | — | Override source path (alternative to `useSPMFileStructure`) | +| `apiStaticMethod` | `true` | API methods are `open class func` (static-style) | + +No external package dependencies are pulled in when `library=urlsession` — the generated `Package.swift` has an empty `dependencies: []` array. + +--- + +## 2. Spec Source + +### Location + +The canonical spec is **dynamically generated** by the Fastify server (using `@fastify/swagger`). It is NOT committed to the `master` branch of `supabase/storage`. + +The spec is published to GitHub Pages on every push to `master` via the `docs.yml` workflow (`npm run docs:export`). The Swagger UI at `https://supabase.github.io/storage/` loads: + +``` +https://supabase.github.io/storage/api.json +``` + +### Pinnable URL + +The `gh-pages` branch holds the rendered output. At time of spike the branch HEAD was: + +``` +SHA: 53e6a743d5b02e7e7e7b7549f7490517773be016 +Date: 2026-04-28T21:30:43Z +``` + +Pinned raw URL (immutable at this SHA): + +``` +https://raw.githubusercontent.com/supabase/storage/53e6a743d5b02e7e7e7b7549f7490517773be016/api.json +``` + +This URL is pinnable but only as a point-in-time snapshot. The `gh-pages` branch advances with every `master` push, so the pin must be refreshed as part of any "bump spec" process. The live tip is always at `https://supabase.github.io/storage/api.json`. + +### Spec Facts + +| Item | Value | +|------|-------| +| Format | JSON | +| OpenAPI version | **3.0.3** | +| Title | Supabase Storage API | +| Spec `version` field | `0.0.0` | +| Total paths | 52 | +| Total operations | 108 | +| Operations with `operationId` | **0** — NONE | +| Schemas in `components/schemas` | 3 (`def-0` authSchema, `def-1` errorSchema, `def-2` vector/iceberg query schema) | +| Security scheme | `bearerAuth` (HTTP Bearer JWT) | +| API tags | `resumable`, `bucket`, `object`, `cdn`, `health`, `iceberg`, `transformation`, `s3`, `vector` | + +**Critical finding: Zero `operationId`s.** Without operationIds the generator synthesizes method names from HTTP method + path (e.g. `bucketBucketIdDelete`, `objectBucketNamePost`). Names are long and ambiguous. The `binding.operationId` approach used in the broader SDK plan cannot rely on the native spec — operationIds must be injected via an overlay or a spec-preprocessing step. + +**Second critical finding: Wildcard path parameters.** 15 of 52 paths use Fastify's `{*}` catch-all syntax (e.g. `/object/{bucketName}/{*}`). This is not standard OpenAPI. The generator produces malformed Swift code with an unnamed parameter (`, : String`), which fails to compile. + +--- + +## 3. Generated Output + +### File Tree (scratch at `$TMPDIR/out`) + +``` +Package.swift +Cartfile (Carthage manifest — not used with SPM) +SupabaseStorage.podspec (CocoaPods manifest — not used) +project.yml (XcodeGen — not used) +Sources/SupabaseStorage/ + APIs/ + BucketAPI.swift (bucket + iceberg-bucket ops, ~700 lines) + CdnAPI.swift (CDN purge — BROKEN: wildcard param) + HealthAPI.swift (health check) + IcebergAPI.swift (Iceberg catalog protocol) + ObjectAPI.swift (object CRUD — BROKEN: wildcard params) + ResumableAPI.swift (TUS resumable — BROKEN: wildcard params) + S3API.swift (S3 compat — BROKEN: wildcard params) + TransformationAPI.swift (image transform — BROKEN: wildcard params) + VectorAPI.swift (vector search) + Infrastructure/ + APIs.swift (SupabaseStorageAPIConfiguration, RequestBuilder) + APIHelper.swift (param encoding helpers) + CodableHelper.swift (JSON encoder/decoder configuration) + Extensions.swift (ParameterConvertible conformances) + JSONDataEncoding.swift (multipart/form-data encoding) + JSONEncodingHelper.swift (body encoding) + JSONValue.swift (AnyCodable-like type — INTERNAL) + Models.swift (ErrorResponse enum — the thrown error type) + OpenAPIMutex.swift (thread-safe state wrapper) + OpenISO8601DateFormatter.swift + SynchronizedDictionary.swift + URLSessionImplementations.swift (URLSession-backed RequestBuilder) + Validation.swift (model property validation) + Models/ + BucketSchema.swift (Bucket entity) + ObjectSchema.swift (Object entity) + Def1.swift (errorSchema — statusCode/error/message) + Def0.swift (authSchema) + Def2.swift (vector query schema) + BucketPost200Response.swift (response types...) + BucketBucketIdPutRequest.swift + ObjectListBucketNamePostRequest.swift + ... (40+ request/response structs) +docs/ (generated markdown docs — 72 files) +``` + +### Package.swift (full content) + +```swift +// swift-tools-version:6.0 + +import PackageDescription + +let package = Package( + name: "SupabaseStorage", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "SupabaseStorage", + targets: ["SupabaseStorage"] + ), + ], + dependencies: [ + // empty — URLSession library has zero external deps + ], + targets: [ + .target( + name: "SupabaseStorage", + dependencies: [], + path: "Sources/SupabaseStorage" + ), + ], + swiftLanguageModes: [.v6] +) +``` + +**No external dependencies.** No AnyCodable, no Alamofire. The generator includes a local `JSONValue.swift` that provides equivalent functionality. + +### Error Type + +The error type in `Infrastructure/Models.swift` is `ErrorResponse` — a Swift enum with associated values. All API methods are declared `async throws(ErrorResponse)` (typed throws, Swift 6 feature). This is clean and modern. + +The `def-1` schema (errorSchema) is emitted as a separate model `Def1` (with fields `statusCode: String`, `error: String`, `message: String`). `Def1` is a distinct type from `ErrorResponse` — `ErrorResponse` is the thrown error type wrapping HTTP-level errors, while `Def1` maps the Storage API's JSON error body. + +### Public vs Internal Split + +| Directory | Role | Public? | +|-----------|------|---------| +| `Sources/SupabaseStorage/Models/` | Domain types (request/response structs) | **Public** — re-export as-is | +| `Sources/SupabaseStorage/APIs/` | API call classes (one per tag) | **Internal** — wrap in a higher-level client | +| `Sources/SupabaseStorage/Infrastructure/` | HTTP transport (URLSession plumbing) | **Internal** — replace or re-use as implementation detail | + +The generator does **not** support a models-only mode. There is no `--additional-properties=generateApiTypes=false` equivalent for `swift6`. However, the `nonPublicApi=true` flag reduces all access modifiers to `internal`, which could be used to make the API classes non-public while a hand-written facade is added as the public surface. + +--- + +## 4. Build Result + +**Build: FAILED** + +``` +swift build → Exit code 1 +Error count: 2,269 (all from the same root cause) +Root cause: Wildcard path parameter `{*}` in 15 paths is not valid OpenAPI. + The generator produces unnamed parameters: `, : String` which is + invalid Swift syntax. +Affected API files: CdnAPI.swift, ObjectAPI.swift, ResumableAPI.swift, + S3API.swift, TransformationAPI.swift +``` + +The Models and Infrastructure files compiled successfully (77 tasks reached before the emit-module failure). The compile errors are confined entirely to the 5 affected API files. Infrastructure and Models are build-clean. + +--- + +## 5. Template Implications + +Three things a `templates/swift/` pack must handle: + +### 5.1 Spec preprocessing is mandatory (not a template concern) + +The raw spec cannot be fed to the generator as-is. A preprocessing step must: + +1. **Inject `operationId`** on every operation (108 ops). The operationId determines Swift method names and is the hook for `binding.operationId` mappings. Strategy: derive from tag + HTTP method + a disambiguating suffix, or maintain a manual `x-operationId` overlay YAML. + +2. **Rename `{*}` to a named parameter** (e.g. `{objectPath}` or `{wildcardPath}`). Standard OpenAPI path templating requires `{paramName}`. This must be done via `sed`/`jq` preprocessing or an overlay. + +3. **Optionally strip Iceberg/Vector/Resumable paths** for the initial pilot — the core Storage SDK (bucket + object) is covered by ~30 of the 108 operations. + +### 5.2 Template overrides for Supabase Swift style + +The generated API classes use `open class func` (static methods) and a global `SupabaseStorageAPIConfiguration.shared` singleton. Supabase's Swift SDK style uses actor-isolated instance clients (`SupabaseStorageClient`). A template override for `api.mustache` should: + +- Change the class to a `struct` or `actor` that holds a configuration instance +- Remove the singleton pattern +- Inject a `baseURL` computed from a Supabase project URL + `/storage/v1` +- Thread the auth token through automatically (the generated code requires explicit `bearerAuth` header management) + +### 5.3 Model naming cleanup + +Schemas are named `def-0`, `def-1`, `def-2` (Fastify's autogenerated names). Template overrides or a spec overlay must rename these to `StorageAuthHeaders`, `StorageError`, and `VectorQueryRequest` (or similar). The response types synthesized from path+method (e.g. `BucketBucketIdEmptyPost200Response`) should also be normalized. + +--- + +## 6. Open Questions / Risks + +| # | Issue | Severity | Suggested resolution | +|---|-------|----------|----------------------| +| 1 | **Zero operationIds** — names are path-derived, verbose, and unstable across spec updates | High | Add operationId injection as a mandatory codegen pre-step; maintain an overlay YAML | +| 2 | **`{*}` wildcard paths** — Fastify-specific, not standard OpenAPI, breaks Swift codegen | High | Preprocess spec with `jq` to rename `{*}` to `{objectPath}` before generation | +| 3 | **Schema names (`def-0`, `def-1`, `def-2`)** — autogenerated, not human-readable | Medium | Overlay or jq preprocessing to rename; or override model name in mustache template via `x-schema-name` | +| 4 | **Spec pinning** — `gh-pages` SHA must be manually bumped; no semver tag on the spec | Medium | Create a `specs/storage/openapi.json` committed copy in this repo; bump it via CI or manual PR | +| 5 | **No models-only mode** — generator always emits full transport stack | Low | Use `nonPublicApi=true` and wrap in a public facade; or use `--global-property=models` to emit only models (undocumented but works in some versions) | +| 6 | **Infrastructure layer ownership** — generated URLSession plumbing vs existing `supabase-swift` transport | Medium | Evaluate reusing existing `_Helpers` transport from `supabase-swift` instead of the generated `URLSessionImplementations.swift` | +| 7 | **Iceberg/Vector/S3 operations** — these are niche and inflate the generated surface | Low | Use path filter or tag filter at generation time; `--global-property=apis=Bucket,Object` | +| 8 | **`statusCode` is `String` not `Int`** in the error schema (Fastify quirk) | Low | Normalize in spec overlay; or handle in model template | + +--- + +## 7. Temp Directory + +All generated scratch output is at: + +``` +/var/folders/jr/ntdntr112251n_sc4mjrn1_80000gn/T/tmp.94vWtvLytn/ + storage-openapi.json — fetched spec (76 KB) + out/ — full generator output (models + APIs + infra) +``` + +This directory is NOT committed to the repo. It will be cleaned by macOS on the next reboot. diff --git a/docs/plans/2026-06-16-sdk-codegen-foundation.md b/docs/plans/2026-06-16-sdk-codegen-foundation.md new file mode 100644 index 0000000..0b1e1ec --- /dev/null +++ b/docs/plans/2026-06-16-sdk-codegen-foundation.md @@ -0,0 +1,961 @@ +# SDK codegen foundation implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the central code-generation contract to `supabase/sdk` — the `binding` field, the `codegen.yaml` config, the generator-args builder, and the conformance-vector format — all validated by the existing matrix validator. + +**Architecture:** Extend the existing TypeScript validator (`scripts/capability-matrix`) additively. Features gain an optional `binding: { spec, operationId }`. A new `codegen.yaml` (validated by a new JSON schema) declares the pinned engine, spec sources, and per-language template packs. A pure `buildGenerateArgs()` turns that config into an `openapi-generator` command line. A new conformance-vector format (validated by a new JSON schema) defines language-agnostic test cases. All checks plug into the existing `run()` so `npm run validate` enforces them. No code is generated in this plan — this is the contract and tooling the per-language plans consume. + +**Tech Stack:** TypeScript (ESM), `tsx`, `vitest`, `ajv`/`ajv-formats` (JSON Schema 2020-12), `yaml`. All already present in `scripts/capability-matrix/package.json`. `openapi-generator` itself is a downstream dependency introduced in the Swift plan, not here. + +**Scope / decomposition:** This is plan 1 of 3 for the Storage pilot. Plan 2 (Swift template pack) and plan 3 (supabase-swift `StorageGen` module) live in the spec at `docs/design/2026-06-16-sdk-code-generation-design.md` and are gated on a discovery spike (running `openapi-generator` against the real Storage spec). This plan delivers working, tested software on its own: the validator enforces bindings, codegen config, and conformance vectors, with fixtures standing in for the real Storage spec. + +**Conventions to follow (from the existing code):** +- ESM imports with **no file extension** (`import { x } from "./types"`) — matches `cli.ts`, `load.ts`, `schema.ts`, and the tests. +- All checks return `Finding[]` (`{ level: "error" | "warning"; file: string; message: string }`). +- Schema validation goes through `compileSchema()` from `src/schema.ts`. +- Tests use `vitest` with `mkdtempSync`/`tmpdir` for file-based cases — mirror `test/structural.test.ts`. +- Run a single test file with: `cd scripts/capability-matrix && npx vitest run test/.test.ts`. + +--- + +### Task 1: Add the `binding` field to features (types + schema) + +**Files:** +- Modify: `scripts/capability-matrix/src/types.ts:25-30` +- Modify: `schema/capability-matrix.schema.json:30-45` +- Test: `scripts/capability-matrix/test/binding-schema.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/binding-schema.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { checkSchema } from "../src/schema"; +import type { LoadedArea } from "../src/types"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "capability-matrix.schema.json"), + "utf8", + ), +); + +function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; +} + +describe("feature binding schema", () => { + it("accepts a feature with a valid binding", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }, + ]); + expect(checkSchema([a], schema)).toEqual([]); + }); + + it("rejects a binding missing operationId", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage" } }, + ]); + expect(checkSchema([a], schema).length).toBeGreaterThan(0); + }); + + it("rejects a binding with an unknown property", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage", operationId: "uploadObject", extra: true } }, + ]); + expect(checkSchema([a], schema).length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/binding-schema.test.ts` +Expected: FAIL — the first case errors because `additionalProperties: false` on the feature currently rejects the unknown `binding` key. + +- [ ] **Step 3: Add the `Binding` type and extend `Feature`** + +In `scripts/capability-matrix/src/types.ts`, replace the `Feature` interface (lines 25-30): + +```ts +export interface Binding { + spec: string; + operationId: string; +} + +export interface Feature { + id: string; + name: string; + description: string; + group?: string; + binding?: Binding; +} +``` + +- [ ] **Step 4: Add `binding` to the JSON schema** + +In `schema/capability-matrix.schema.json`, add `binding` to the feature's `properties` (after the `group` property, line 43), and add a `binding` definition under `$defs`: + +```json + "group": { "type": "string", "minLength": 1 }, + "binding": { "$ref": "#/$defs/binding" } +``` + +```json + "$defs": { + "feature": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "description"], + "properties": { + "id": { + "type": "string", + "description": "Three-segment dotted id: ... Example: auth.mfa.challenge", + "pattern": "^[a-z][a-z0-9_]*\\.[a-z0-9_]+\\.[a-z0-9_]+$" + }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "group": { "type": "string", "minLength": 1 }, + "binding": { "$ref": "#/$defs/binding" } + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["spec", "operationId"], + "properties": { + "spec": { "type": "string", "minLength": 1, "description": "Spec id declared in codegen.yaml" }, + "operationId": { "type": "string", "minLength": 1, "description": "OpenAPI operationId this feature maps to" } + } + } + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/binding-schema.test.ts` +Expected: PASS (all three cases). + +- [ ] **Step 6: Typecheck and commit** + +Run: `cd scripts/capability-matrix && npm run typecheck` +Expected: no errors. + +```bash +git add scripts/capability-matrix/src/types.ts schema/capability-matrix.schema.json scripts/capability-matrix/test/binding-schema.test.ts +git commit -m "feat: add optional binding field to capability features" +``` + +--- + +### Task 2: Add the `codegen.yaml` schema, types, and loader + +**Files:** +- Create: `schema/codegen.schema.json` +- Create: `scripts/capability-matrix/src/codegen.ts` +- Test: `scripts/capability-matrix/test/codegen.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/codegen.test.ts`: + +```ts +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { loadCodegenConfig, checkCodegenConfig } from "../src/codegen"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "codegen.schema.json"), + "utf8", + ), +); + +const valid = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "https://example.com/storage.yaml", version: "v1.2.3" } }, + languages: { swift: { generator: "swift5", templates: "templates/swift" } }, +}; + +describe("checkCodegenConfig", () => { + it("accepts a valid config", () => { + expect(checkCodegenConfig(valid, schema)).toEqual([]); + }); + + it("rejects a config missing engine.version", () => { + const bad = { ...valid, engine: { tool: "openapi-generator" } }; + expect(checkCodegenConfig(bad, schema).length).toBeGreaterThan(0); + }); + + it("rejects a language missing its generator", () => { + const bad = { ...valid, languages: { swift: { templates: "templates/swift" } } }; + expect(checkCodegenConfig(bad, schema).length).toBeGreaterThan(0); + }); +}); + +describe("loadCodegenConfig", () => { + it("parses a YAML config file", () => { + const dir = mkdtempSync(join(tmpdir(), "codegen-")); + const file = join(dir, "codegen.yaml"); + writeFileSync(file, "engine:\n tool: openapi-generator\n version: 7.10.0\nspecs:\n storage:\n source: x\n version: v1\nlanguages:\n swift:\n generator: swift5\n templates: templates/swift\n"); + const { config, findings } = loadCodegenConfig(file); + expect(findings).toEqual([]); + expect(config?.engine.version).toBe("7.10.0"); + expect(config?.specs.storage.source).toBe("x"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/codegen.test.ts` +Expected: FAIL — `../src/codegen` and `schema/codegen.schema.json` do not exist yet. + +- [ ] **Step 3: Create the codegen schema** + +Create `schema/codegen.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/sdk/codegen.schema.json", + "title": "Supabase SDK codegen config", + "type": "object", + "additionalProperties": false, + "required": ["engine", "specs", "languages"], + "properties": { + "engine": { + "type": "object", + "additionalProperties": false, + "required": ["tool", "version"], + "properties": { + "tool": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + }, + "specs": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["source", "version"], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + } + }, + "languages": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["generator", "templates"], + "properties": { + "generator": { "type": "string", "minLength": 1 }, + "templates": { "type": "string", "minLength": 1 }, + "generatorProperties": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } + } + } +} +``` + +- [ ] **Step 4: Create the loader and validator** + +Create `scripts/capability-matrix/src/codegen.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; +import { compileSchema } from "./schema"; +import type { Finding } from "./types"; + +export interface SpecSource { + source: string; + version: string; +} + +export interface LanguageConfig { + generator: string; + templates: string; + generatorProperties?: Record; +} + +export interface CodegenConfig { + engine: { tool: string; version: string }; + specs: Record; + languages: Record; +} + +export function loadCodegenConfig(file: string): { config?: CodegenConfig; findings: Finding[] } { + try { + const config = parse(readFileSync(file, "utf8")) as CodegenConfig; + return { config, findings: [] }; + } catch (e) { + return { findings: [{ level: "error", file, message: `codegen config parse error: ${(e as Error).message}` }] }; + } +} + +export function checkCodegenConfig(config: unknown, schema: object, file = "codegen.yaml"): Finding[] { + const validate = compileSchema(schema); + if (validate(config)) return []; + return (validate.errors ?? []).map((err) => ({ + level: "error" as const, + file, + message: `codegen schema: ${err.instancePath || "/"} ${err.message ?? "invalid"}`, + })); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/codegen.test.ts` +Expected: PASS. + +- [ ] **Step 6: Typecheck and commit** + +Run: `cd scripts/capability-matrix && npm run typecheck` +Expected: no errors. + +```bash +git add schema/codegen.schema.json scripts/capability-matrix/src/codegen.ts scripts/capability-matrix/test/codegen.test.ts +git commit -m "feat: add codegen.yaml schema, types, and loader" +``` + +--- + +### Task 3: Cross-validate feature bindings against the codegen config + +**Files:** +- Create: `scripts/capability-matrix/src/bindings.ts` +- Test: `scripts/capability-matrix/test/bindings.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/bindings.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { checkBindings } from "../src/bindings"; +import type { LoadedArea } from "../src/types"; +import type { CodegenConfig } from "../src/codegen"; + +const config: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "x", version: "v1" } }, + languages: { swift: { generator: "swift5", templates: "templates/swift" } }, +}; + +function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; +} + +describe("checkBindings", () => { + it("passes when a binding references a known spec", () => { + const a = area([ + { id: "storage.objects.upload", name: "U", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }, + ]); + expect(checkBindings([a], config)).toEqual([]); + }); + + it("ignores features without a binding", () => { + const a = area([{ id: "storage.objects.upload", name: "U", description: "d" }]); + expect(checkBindings([a], config)).toEqual([]); + }); + + it("errors when a binding references an unknown spec", () => { + const a = area([ + { id: "storage.objects.upload", name: "U", description: "d", binding: { spec: "ghost", operationId: "x" } }, + ]); + const findings = checkBindings([a], config); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('unknown spec "ghost"'); + }); +}); +``` + +Note: `LoadedArea` comes from `../src/types`; `CodegenConfig` comes from `../src/codegen` (added in Task 2). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/bindings.test.ts` +Expected: FAIL — `../src/bindings` does not exist. + +- [ ] **Step 3: Create the binding checker** + +Create `scripts/capability-matrix/src/bindings.ts`: + +```ts +import type { CodegenConfig } from "./codegen"; +import type { Finding, LoadedArea } from "./types"; + +export function checkBindings(loaded: LoadedArea[], config: CodegenConfig): Finding[] { + const findings: Finding[] = []; + for (const { file, area } of loaded) { + for (const feature of area?.features ?? []) { + const binding = feature.binding; + if (!binding) continue; + if (!config.specs[binding.spec]) { + findings.push({ + level: "error", + file, + message: `feature "${feature.id}" binds to unknown spec "${binding.spec}" (not declared in codegen config)`, + }); + } + } + } + return findings; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/bindings.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and commit** + +Run: `cd scripts/capability-matrix && npm run typecheck` +Expected: no errors. + +```bash +git add scripts/capability-matrix/src/bindings.ts scripts/capability-matrix/test/bindings.test.ts +git commit -m "feat: validate feature bindings against codegen config" +``` + +--- + +### Task 4: Build the openapi-generator argument builder + +**Files:** +- Create: `scripts/capability-matrix/src/generate.ts` +- Test: `scripts/capability-matrix/test/generate.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/generate.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildGenerateArgs } from "../src/generate"; +import type { CodegenConfig } from "../src/codegen"; + +const config: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "https://example.com/storage.yaml", version: "v1" } }, + languages: { + swift: { generator: "swift5", templates: "templates/swift", generatorProperties: { library: "urlsession", useJsonEncodable: "false" } }, + }, +}; + +describe("buildGenerateArgs", () => { + it("builds the generate command for a target", () => { + const args = buildGenerateArgs(config, { spec: "storage", language: "swift", outDir: "generated/storage" }); + expect(args).toEqual([ + "generate", + "--input-spec", "https://example.com/storage.yaml", + "--generator-name", "swift5", + "--output", "generated/storage", + "--template-dir", "templates/swift", + "--additional-properties=library=urlsession,useJsonEncodable=false", + ]); + }); + + it("omits --additional-properties when there are none", () => { + const bare: CodegenConfig = { ...config, languages: { swift: { generator: "swift5", templates: "templates/swift" } } }; + const args = buildGenerateArgs(bare, { spec: "storage", language: "swift", outDir: "out" }); + expect(args).not.toContain("--additional-properties"); + expect(args.some((a) => a.startsWith("--additional-properties"))).toBe(false); + }); + + it("throws on an unknown spec", () => { + expect(() => buildGenerateArgs(config, { spec: "ghost", language: "swift", outDir: "out" })).toThrow(/unknown spec/); + }); + + it("throws on an unknown language", () => { + expect(() => buildGenerateArgs(config, { spec: "storage", language: "cobol", outDir: "out" })).toThrow(/unknown language/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/generate.test.ts` +Expected: FAIL — `../src/generate` does not exist. + +- [ ] **Step 3: Create the argument builder** + +Create `scripts/capability-matrix/src/generate.ts`: + +```ts +import type { CodegenConfig } from "./codegen"; + +export interface GenerateTarget { + spec: string; + language: string; + outDir: string; +} + +/** + * Builds the argv for `openapi-generator-cli generate` from the codegen config + * and a target. Pure function — the engine version pin is applied by the + * openapi-generator-cli toolchain (openapitools.json), not here. + */ +export function buildGenerateArgs(config: CodegenConfig, target: GenerateTarget): string[] { + const spec = config.specs[target.spec]; + if (!spec) throw new Error(`unknown spec "${target.spec}" (not declared in codegen config)`); + const lang = config.languages[target.language]; + if (!lang) throw new Error(`unknown language "${target.language}" (not declared in codegen config)`); + + const args = [ + "generate", + "--input-spec", spec.source, + "--generator-name", lang.generator, + "--output", target.outDir, + "--template-dir", lang.templates, + ]; + + const extra = lang.generatorProperties; + if (extra && Object.keys(extra).length > 0) { + const pairs = Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(","); + args.push(`--additional-properties=${pairs}`); + } + + return args; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/generate.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and commit** + +Run: `cd scripts/capability-matrix && npm run typecheck` +Expected: no errors. + +```bash +git add scripts/capability-matrix/src/generate.ts scripts/capability-matrix/test/generate.test.ts +git commit -m "feat: add openapi-generator argument builder" +``` + +--- + +### Task 5: Add the conformance-vector format and validator + +**Files:** +- Create: `schema/conformance.schema.json` +- Create: `scripts/capability-matrix/src/conformance.ts` +- Test: `scripts/capability-matrix/test/conformance.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/conformance.test.ts`: + +```ts +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { checkConformance } from "../src/conformance"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "conformance.schema.json"), + "utf8", + ), +); + +function makeDir(files: Record): string { + const tmp = mkdtempSync(join(tmpdir(), "conf-")); + for (const [rel, content] of Object.entries(files)) { + const parts = rel.split("/"); + if (parts.length > 1) mkdirSync(join(tmp, ...parts.slice(0, -1)), { recursive: true }); + writeFileSync(join(tmp, rel), content); + } + return tmp; +} + +const validVector = "feature: storage.objects.upload\ncases:\n - name: uploads a small file\n input: { path: a.txt, body: hi }\n expected: { status: 200 }\n"; + +describe("checkConformance", () => { + it("passes when a vector is well-formed and references a known feature", () => { + const dir = makeDir({ "storage/upload.yaml": validVector }); + expect(checkConformance(dir, new Set(["storage.objects.upload"]), schema)).toEqual([]); + }); + + it("errors when a vector references an unknown feature", () => { + const dir = makeDir({ "storage/upload.yaml": validVector }); + const findings = checkConformance(dir, new Set(["storage.objects.list"]), schema); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("storage.objects.upload"); + }); + + it("errors when a vector is malformed (missing cases)", () => { + const dir = makeDir({ "storage/bad.yaml": "feature: storage.objects.upload\n" }); + expect(checkConformance(dir, new Set(["storage.objects.upload"]), schema).length).toBeGreaterThan(0); + }); + + it("returns empty when the conformance directory does not exist", () => { + expect(checkConformance("/nonexistent/conf-xyzzy", new Set(), schema)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/conformance.test.ts` +Expected: FAIL — `../src/conformance` and `schema/conformance.schema.json` do not exist yet. + +- [ ] **Step 3: Create the conformance schema** + +Create `schema/conformance.schema.json`: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/sdk/conformance.schema.json", + "title": "Supabase SDK conformance vector file", + "type": "object", + "additionalProperties": false, + "required": ["feature", "cases"], + "properties": { + "feature": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*\\.[a-z0-9_]+\\.[a-z0-9_]+$" + }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "input", "expected"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "input": {}, + "expected": {} + } + } + } + } +} +``` + +- [ ] **Step 4: Create the conformance loader/validator** + +Create `scripts/capability-matrix/src/conformance.ts`: + +```ts +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { compileSchema } from "./schema"; +import type { Finding } from "./types"; + +function collectFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectFiles(p)); + else if (entry.name.endsWith(".yaml")) out.push(p); + } + return out; +} + +export function checkConformance(dir: string, knownIds: Set, schema: object): Finding[] { + const findings: Finding[] = []; + const validate = compileSchema(schema); + + let files: string[]; + try { + files = collectFiles(dir); + } catch { + return findings; // conformance dir absent + } + + for (const file of files) { + let doc: unknown; + try { + doc = parse(readFileSync(file, "utf8")); + } catch (e) { + findings.push({ level: "error", file, message: `YAML parse error: ${(e as Error).message}` }); + continue; + } + if (!validate(doc)) { + for (const err of validate.errors ?? []) { + findings.push({ level: "error", file, message: `conformance: ${err.instancePath || "/"} ${err.message ?? "invalid"}` }); + } + continue; + } + const feature = (doc as { feature: string }).feature; + if (!knownIds.has(feature)) { + findings.push({ level: "error", file, message: `conformance vector references unknown feature id "${feature}"` }); + } + } + return findings; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/conformance.test.ts` +Expected: PASS. + +- [ ] **Step 6: Typecheck and commit** + +Run: `cd scripts/capability-matrix && npm run typecheck` +Expected: no errors. + +```bash +git add schema/conformance.schema.json scripts/capability-matrix/src/conformance.ts scripts/capability-matrix/test/conformance.test.ts +git commit -m "feat: add conformance vector format and validator" +``` + +--- + +### Task 6: Wire the new checks into the validator `run()` + +**Files:** +- Modify: `scripts/capability-matrix/src/cli.ts:1-42` and `:49-62` +- Test: `scripts/capability-matrix/test/run-codegen.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `scripts/capability-matrix/test/run-codegen.test.ts`: + +```ts +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { run } from "../src/cli"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const schema = JSON.parse(readFileSync(join(root, "schema", "capability-matrix.schema.json"), "utf8")); +const codegenSchema = JSON.parse(readFileSync(join(root, "schema", "codegen.schema.json"), "utf8")); + +describe("run() with codegen checks", () => { + it("flags a feature bound to a spec absent from codegen.yaml", async () => { + const capDir = mkdtempSync(join(tmpdir(), "cap-")); + writeFileSync( + join(capDir, "storage.yaml"), + "area: storage\ntitle: Storage\ndescription: d\nfeatures:\n - id: storage.objects.upload\n name: Upload\n description: d\n binding:\n spec: ghost\n operationId: uploadObject\n", + ); + const cfgDir = mkdtempSync(join(tmpdir(), "cfg-")); + const cfgPath = join(cfgDir, "codegen.yaml"); + writeFileSync( + cfgPath, + "engine:\n tool: openapi-generator\n version: 7.10.0\nspecs:\n storage:\n source: x\n version: v1\nlanguages:\n swift:\n generator: swift5\n templates: templates/swift\n", + ); + + const result = await run({ mode: "validate", capabilitiesDir: capDir, schema, codegenConfigPath: cfgPath, codegenSchema }); + expect(result.findings.some((f) => f.message.includes('unknown spec "ghost"'))).toBe(true); + expect(result.errorCount).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd scripts/capability-matrix && npx vitest run test/run-codegen.test.ts` +Expected: FAIL — `RunOptions` has no `codegenConfigPath`/`codegenSchema`, so `run()` never reports the binding error (TypeScript error and/or assertion failure). + +- [ ] **Step 3: Extend `RunOptions` and `run()`** + +In `scripts/capability-matrix/src/cli.ts`, update the imports at the top (lines 1-8): + +```ts +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { loadAreas } from "./load"; +import { checkSchema } from "./schema"; +import { checkStructural, checkSpecs } from "./structural"; +import { loadCodegenConfig, checkCodegenConfig } from "./codegen"; +import { checkBindings } from "./bindings"; +import { checkConformance } from "./conformance"; +import { computeParity, type ParityReport } from "./report"; +import type { Finding } from "./types"; +``` + +Replace the `RunOptions` interface (lines 10-16): + +```ts +export interface RunOptions { + mode: "validate" | "report"; + capabilitiesDir: string; + schema: object; + specsDir?: string; + changedFiles?: string[]; + codegenConfigPath?: string; + codegenSchema?: object; + conformanceDir?: string; + conformanceSchema?: object; +} +``` + +Replace the validate-mode body of `run()` (lines 31-41) with: + +```ts + const findings: Finding[] = [...loadFindings]; + findings.push(...checkSchema(areas, opts.schema)); + findings.push(...checkStructural(areas)); + + const knownIds = new Set(areas.flatMap((a) => a.area.features.map((f) => f.id))); + + if (opts.specsDir) { + findings.push(...checkSpecs(opts.specsDir, knownIds)); + } + + if (opts.codegenConfigPath && opts.codegenSchema && existsSync(opts.codegenConfigPath)) { + const { config, findings: loadFindings2 } = loadCodegenConfig(opts.codegenConfigPath); + findings.push(...loadFindings2); + if (config) { + findings.push(...checkCodegenConfig(config, opts.codegenSchema, opts.codegenConfigPath)); + findings.push(...checkBindings(areas, config)); + } + } + + if (opts.conformanceDir && opts.conformanceSchema) { + findings.push(...checkConformance(opts.conformanceDir, knownIds, opts.conformanceSchema)); + } + + const errorCount = findings.filter((f) => f.level === "error").length; + return { findings, errorCount }; +``` + +- [ ] **Step 4: Wire the new paths into `main()`** + +In `scripts/capability-matrix/src/cli.ts`, update the `run({...})` call inside `main()` (lines 55-62) to pass the codegen config and conformance dir: + +```ts + const schema = JSON.parse(readFileSync(join(root, "schema", "capability-matrix.schema.json"), "utf8")); + const codegenSchema = JSON.parse(readFileSync(join(root, "schema", "codegen.schema.json"), "utf8")); + const conformanceSchema = JSON.parse(readFileSync(join(root, "schema", "conformance.schema.json"), "utf8")); + const result = await run({ + mode, + capabilitiesDir: join(root, "capabilities"), + specsDir: join(root, "specs"), + schema, + codegenConfigPath: join(root, "codegen.yaml"), + codegenSchema, + conformanceDir: join(root, "conformance"), + conformanceSchema, + changedFiles: positionals.length > 0 ? positionals : undefined, + }); +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd scripts/capability-matrix && npx vitest run test/run-codegen.test.ts` +Expected: PASS. + +- [ ] **Step 6: Run the full suite and typecheck** + +Run: `cd scripts/capability-matrix && npm test && npm run typecheck` +Expected: all tests pass, no type errors. (The existing `npm run validate` still passes because `codegen.yaml` and `conformance/` are absent at the repo root, so those checks are skipped.) + +- [ ] **Step 7: Commit** + +```bash +git add scripts/capability-matrix/src/cli.ts scripts/capability-matrix/test/run-codegen.test.ts +git commit -m "feat: enforce codegen bindings and conformance vectors in validate" +``` + +--- + +### Task 7: Document the contract in the README + +**Files:** +- Modify: `README.md` (append a "Code generation contract" section after "Adding or updating a capability") + +- [ ] **Step 1: Add the documentation** + +Append this section to `README.md` (place it after the "Adding or updating a capability" section): + +````markdown +## Code generation contract + +SDKs generate their transport, models, and error types from upstream OpenAPI specs. This repo is the contract: + +- A feature may declare an optional `binding` to the OpenAPI operation it maps to: + + ```yaml + - id: storage.objects.upload + name: Upload Object + description: Upload a file to a bucket. + group: objects + binding: + spec: storage # must match a spec id in codegen.yaml + operationId: uploadObject + ``` + +- `codegen.yaml` (repo root) pins the generator engine, the spec sources, and the per-language template packs: + + ```yaml + engine: + tool: openapi-generator + version: 7.10.0 + specs: + storage: + source: https://.../storage/openapi.yaml + version: + languages: + swift: + generator: swift5 + templates: templates/swift + ``` + +- `conformance/**/*.yaml` holds language-agnostic test vectors each SDK runs: + + ```yaml + feature: storage.objects.upload + cases: + - name: uploads a small file + input: { path: a.txt, body: hi } + expected: { status: 200 } + ``` + +`npm run validate` enforces that bindings reference declared specs, that `codegen.yaml` matches its schema, and that conformance vectors are well-formed and reference real features. The full schemas live in `schema/codegen.schema.json` and `schema/conformance.schema.json`. +```` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document the code generation contract" +``` + +--- + +## Follow-on plans (not in this plan) + +These are gated on the discovery spike and/or live in another repo; they get their own plans once this foundation lands. + +- **Plan 2 — Swift template pack + generation (this repo).** Open with a spike: run the pinned `openapi-generator` against the real Storage OpenAPI spec, inspect the default Swift output, and choose the generator id (`swift5`/`swift6`/etc.). Then author `codegen.yaml` for storage+swift, add real `binding`s to `capabilities/storage.yaml`, create `templates/swift/` tuned to emit public models/errors + internal transport, add `conformance/storage/` vectors, and add a thin spawn runner around `buildGenerateArgs`. +- **Plan 3 — supabase-swift `StorageGen` module (supabase-swift repo).** A parallel Storage target built on the generated core + hand-written surface, with the conformance vectors wired into the test suite and behavior parity demonstrated against the shipped `Storage` module. Cutover is a separate, later decision. + +## Self-review notes + +- **Spec coverage:** binding field (spec §6) → Task 1; `codegen.yaml` (§6) → Task 2; binding↔config validation / "no honor system" (§6) → Tasks 3, 6; `make generate` arg construction (§8) → Task 4 (spawn runner deferred to Plan 2, where a real spec exists to run it against); conformance format (§9) → Task 5; committed/CI enforcement (§8) → Task 6 via `npm run validate`. PostgREST/Realtime (§10), the Swift module (§11), and rollout (§12) are explicitly out of this plan. +- **Type consistency:** `Binding { spec, operationId }`, `CodegenConfig { engine, specs, languages }`, `LanguageConfig { generator, templates, generatorProperties? }`, `GenerateTarget { spec, language, outDir }`, and `checkBindings`/`checkCodegenConfig`/`checkConformance`/`buildGenerateArgs` names are used identically across tasks and tests. +- **No placeholders:** every step ships real code, real commands, and expected output. diff --git a/docs/plans/2026-06-16-supabase-runtime-plan.md b/docs/plans/2026-06-16-supabase-runtime-plan.md new file mode 100644 index 0000000..90f1c01 --- /dev/null +++ b/docs/plans/2026-06-16-supabase-runtime-plan.md @@ -0,0 +1,1189 @@ +# SupabaseRuntime (Swift) Implementation Plan — Plan A + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `SupabaseRuntime`, a generic, hand-written Swift 6 package implementing the `Transport` seam (send / upload / download / stream) over `URLSession`, with streaming I/O, progress, background sessions, and typed errors — buildable and testable with zero code generation. + +**Architecture:** A standalone SwiftPM library package. Pure value types and a small protocol form the contract; a `URLSessionTransport` actor implements it with native async `URLSession`. A `MockTransport` enables network-free testing of downstream layers. Plan B (separate) will generate a thin client against this package. + +**Tech Stack:** Swift 6 (language mode), SwiftPM, `swift-testing` (bundled with the toolchain — no external dependency), `URLSession`. Zero external package dependencies. + +## Global Constraints + +- `swift-tools-version: 6.0`; Swift 6 language mode with complete strict concurrency. +- Platforms: `.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)` (floor set by async `URLSession.data(for:)` / `bytes(for:)`). +- **Zero external dependencies.** Foundation + `swift-testing` only. +- No `@unchecked Sendable` and no manual locks — use actor isolation. +- All public types are `public` and `Sendable`. +- Package lives at `codegen/runtime/swift/SupabaseRuntime/`. Run everything from there: `swift build`, `swift test`, `swift test --filter `. + +--- + +### Task 1: Package scaffold + request value types + RequestPath + TransportError + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Package.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/HTTPRequest.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/RequestPath.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/TransportError.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/RequestPathTests.swift` + +**Interfaces:** +- Produces: `HTTPMethod`, `HTTPRequest`, `HTTPResponseHead`, `UploadSource` (value types); `TransportError` (error enum); `RequestPath` percent-encoding via `String(stringInterpolation:)` so `"/x/\(param: value)"` escapes `value`. + +- [ ] **Step 1: Create `Package.swift`** + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SupabaseRuntime", + platforms: [.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8)], + products: [.library(name: "SupabaseRuntime", targets: ["SupabaseRuntime"])], + targets: [ + .target( + name: "SupabaseRuntime", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "SupabaseRuntimeTests", + dependencies: ["SupabaseRuntime"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) +``` + +- [ ] **Step 2: Write the failing test** — `Tests/SupabaseRuntimeTests/RequestPathTests.swift`: + +```swift +import Testing +@testable import SupabaseRuntime + +@Suite struct RequestPathTests { + @Test func percentEncodesInterpolatedParams() { + let bucket = "my bucket" + let key = "folder/cat.png" + let path = RequestPath("/object/\(param: bucket)/\(param: key)") + #expect(path.value == "/object/my%20bucket/folder%2Fcat.png") + } + + @Test func leavesLiteralSegmentsUntouched() { + let path = RequestPath("/bucket/") + #expect(path.value == "/bucket/") + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter RequestPathTests` +Expected: FAIL — `RequestPath` does not exist. + +- [ ] **Step 4: Implement the value types** — `Sources/SupabaseRuntime/HTTPRequest.swift`: + +```swift +import Foundation + +public enum HTTPMethod: String, Sendable { + case get, post, put, delete, patch, head +} + +public struct HTTPRequest: Sendable { + public var method: HTTPMethod + public var path: String + public var query: [URLQueryItem] + public var headers: [String: String] + + public init(method: HTTPMethod, path: String, query: [URLQueryItem] = [], headers: [String: String] = [:]) { + self.method = method + self.path = path + self.query = query + self.headers = headers + } + + /// Convenience so generated code can write `HTTPRequest(method: .get, path: "/x/\(param: id)")`. + public init(method: HTTPMethod, path: RequestPath, query: [URLQueryItem] = [], headers: [String: String] = [:]) { + self.init(method: method, path: path.value, query: query, headers: headers) + } +} + +public struct HTTPResponseHead: Sendable { + public let status: Int + public let headers: [String: String] + public init(status: Int, headers: [String: String]) { + self.status = status + self.headers = headers + } +} + +public enum UploadSource: Sendable { + case file(URL) + case data(Data) +} +``` + +- [ ] **Step 5: Implement `RequestPath`** — `Sources/SupabaseRuntime/RequestPath.swift`: + +```swift +import Foundation + +/// A URL path built from a string literal whose interpolated `\(param:)` segments are percent-encoded. +public struct RequestPath: Sendable, ExpressibleByStringInterpolation { + public let value: String + + public init(stringLiteral value: String) { self.value = value } + public init(stringInterpolation: StringInterpolation) { self.value = stringInterpolation.text } + public init(_ path: RequestPath) { self.value = path.value } + + public struct StringInterpolation: StringInterpolationProtocol { + var text = "" + public init(literalCapacity: Int, interpolationCount: Int) { text.reserveCapacity(literalCapacity) } + public mutating func appendLiteral(_ literal: String) { text += literal } + /// Interpolated path parameter — percent-encoded for a path segment (so `/` becomes `%2F`). + public mutating func appendInterpolation(param value: String) { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + text += value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + } +} +``` + +- [ ] **Step 6: Implement `TransportError`** — `Sources/SupabaseRuntime/TransportError.swift`: + +```swift +import Foundation + +/// The error the runtime throws when no `ClientConfiguration.errorMapper` produces a typed error. +public enum TransportError: Error, Sendable { + case http(status: Int, body: Data, head: HTTPResponseHead) + case transport(any Error) + case decoding(any Error) + case cancelled + /// Background sessions reject in-memory bodies. + case backgroundRequiresFile +} +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter RequestPathTests` +Expected: PASS (both cases). + +- [ ] **Step 8: Verify the whole package builds, then commit** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift build && swift test` +Expected: build succeeds, tests pass. + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): scaffold SupabaseRuntime with request value types" +``` + +--- + +### Task 2: Streaming types — TransferProgress, TransferTask, ResponseStream + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Streaming.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/TransferTaskTests.swift` + +**Interfaces:** +- Produces: `TransferProgress` (struct); `TransferTask` (handle with `progress: AsyncStream`, `value() async throws -> Value`, `cancel()`, built from closures so it's testable in isolation); `ResponseStream` (head + `AsyncThrowingStream, any Error>`). + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/TransferTaskTests.swift`: + +```swift +import Testing +@testable import SupabaseRuntime + +@Suite struct TransferTaskTests { + @Test func deliversProgressThenValue() async throws { + let (stream, cont) = AsyncStream.makeStream() + let task = TransferTask( + progress: stream, + value: { 42 }, + cancel: {} + ) + cont.yield(TransferProgress(completed: 5, total: 10)) + cont.finish() + + var seen: [Int64] = [] + for await p in task.progress { seen.append(p.completed) } + let value = try await task.value() + + #expect(seen == [5]) + #expect(value == 42) + } + + @Test func fractionComputesWhenTotalKnown() { + #expect(TransferProgress(completed: 5, total: 10).fraction == 0.5) + #expect(TransferProgress(completed: 5, total: nil).fraction == nil) + } + + @Test func cancelInvokesClosure() async { + let flag = Mutexish() + let task = TransferTask(progress: .init { $0.finish() }, value: { 0 }, cancel: { flag.set() }) + task.cancel() + #expect(flag.get() == true) + } +} + +// Minimal actor helper for the cancel test (no production use). +private final class Mutexish: @unchecked Sendable { + private let lock = NSLock(); private var flag = false + func set() { lock.lock(); flag = true; lock.unlock() } + func get() -> Bool { lock.lock(); defer { lock.unlock() }; return flag } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter TransferTaskTests` +Expected: FAIL — `TransferProgress`/`TransferTask`/`ResponseStream` do not exist. + +- [ ] **Step 3: Implement** — `Sources/SupabaseRuntime/Streaming.swift`: + +```swift +import Foundation + +public struct TransferProgress: Sendable { + public let completed: Int64 + public let total: Int64? + public init(completed: Int64, total: Int64?) { + self.completed = completed + self.total = total + } + public var fraction: Double? { + guard let total, total > 0 else { return nil } + return Double(completed) / Double(total) + } +} + +/// Handle for an in-flight upload/download: live progress + the awaitable result. +public struct TransferTask: Sendable { + public let progress: AsyncStream + private let _value: @Sendable () async throws -> Value + private let _cancel: @Sendable () -> Void + + public init( + progress: AsyncStream, + value: @escaping @Sendable () async throws -> Value, + cancel: @escaping @Sendable () -> Void + ) { + self.progress = progress + self._value = value + self._cancel = cancel + } + + public func value() async throws -> Value { try await _value() } + public func cancel() { _cancel() } +} + +/// A streamed response body (event streams / SSE / incremental reads). +public struct ResponseStream: Sendable { + public let head: HTTPResponseHead + public let body: AsyncThrowingStream, any Error> + public init(head: HTTPResponseHead, body: AsyncThrowingStream, any Error>) { + self.head = head + self.body = body + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter TransferTaskTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): add TransferProgress, TransferTask, ResponseStream" +``` + +--- + +### Task 3: Transport protocol + MockTransport + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/Transport.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/MockTransport.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/MockTransportTests.swift` + +**Interfaces:** +- Consumes: `HTTPRequest`, `UploadSource`, `TransferTask`, `ResponseStream`, `HTTPResponseHead` (Tasks 1-2). +- Produces: `Transport` protocol (the seam); `MockTransport` — a `Transport` whose `respond: @Sendable (HTTPRequest) async throws -> (Int, Data)` returns a status + body, decoded with an injected `JSONDecoder`. + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/MockTransportTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct Bucket: Codable, Equatable, Sendable { let id: String } + +@Suite struct MockTransportTests { + @Test func sendDecodesBody() async throws { + let mock = MockTransport { _ in (200, #"{"id":"abc"}"#.data(using: .utf8)!) } + let bucket: Bucket = try await mock.send(HTTPRequest(method: .get, path: "/bucket/abc")) + #expect(bucket == Bucket(id: "abc")) + } + + @Test func uploadReturnsValue() async throws { + let mock = MockTransport { _ in (200, #"{"id":"up"}"#.data(using: .utf8)!) } + let task: TransferTask = mock.upload(HTTPRequest(method: .post, path: "/object/b/k"), from: .data(Data())) + let bucket = try await task.value() + #expect(bucket == Bucket(id: "up")) + } + + @Test func streamYieldsBody() async throws { + let mock = MockTransport { _ in (200, Data([1, 2, 3])) } + let response = try await mock.stream(HTTPRequest(method: .get, path: "/events")) + #expect(response.head.status == 200) + var bytes: [UInt8] = [] + for try await chunk in response.body { bytes.append(contentsOf: chunk) } + #expect(bytes == [1, 2, 3]) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter MockTransportTests` +Expected: FAIL — `Transport`/`MockTransport` do not exist. + +- [ ] **Step 3: Implement the protocol** — `Sources/SupabaseRuntime/Transport.swift`: + +```swift +import Foundation + +public protocol Transport: Sendable { + func send(_ request: HTTPRequest) async throws -> R + func send(_ request: HTTPRequest, body: B) async throws -> R + func send(_ request: HTTPRequest) async throws + func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask + func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask + func stream(_ request: HTTPRequest) async throws -> ResponseStream +} +``` + +- [ ] **Step 4: Implement `MockTransport`** — `Sources/SupabaseRuntime/MockTransport.swift`: + +```swift +import Foundation + +/// In-memory `Transport` for tests: `respond` maps a request to a (status, body) pair. +public struct MockTransport: Transport { + public typealias Responder = @Sendable (HTTPRequest) async throws -> (Int, Data) + let responder: Responder + let decoder: JSONDecoder + + public init(decoder: JSONDecoder = JSONDecoder(), _ responder: @escaping Responder) { + self.responder = responder + self.decoder = decoder + } + + public func send(_ request: HTTPRequest) async throws -> R { + let (_, data) = try await responder(request) + return try decoder.decode(R.self, from: data) + } + + public func send(_ request: HTTPRequest, body: B) async throws -> R { + try await send(request) + } + + public func send(_ request: HTTPRequest) async throws { + _ = try await responder(request) + } + + public func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + let responder = self.responder + let decoder = self.decoder + let (stream, cont) = AsyncStream.makeStream() + cont.finish() + return TransferTask( + progress: stream, + value: { let (_, data) = try await responder(request); return try decoder.decode(R.self, from: data) }, + cancel: {} + ) + } + + public func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask { + let responder = self.responder + let (stream, cont) = AsyncStream.makeStream() + cont.finish() + return TransferTask( + progress: stream, + value: { let (_, data) = try await responder(request); try data.write(to: destination) }, + cancel: {} + ) + } + + public func stream(_ request: HTTPRequest) async throws -> ResponseStream { + let (status, data) = try await responder(request) + let body = AsyncThrowingStream, any Error> { cont in + cont.yield(ArraySlice(data)) + cont.finish() + } + return ResponseStream(head: HTTPResponseHead(status: status, headers: [:]), body: body) + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter MockTransportTests` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): add Transport protocol and MockTransport" +``` + +--- + +### Task 4: ClientConfiguration + AuthProvider + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/AuthProvider.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/ClientConfiguration.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/ClientConfigurationTests.swift` + +**Interfaces:** +- Produces: `AuthProvider` (wraps `@Sendable () async throws -> [String: String]`); `SessionKind` (`.foreground` / `.background(identifier:)`); `ClientConfiguration` (baseURL, defaultHeaders, encoder, decoder, errorMapper, sessionKind, auth) consumed by `URLSessionTransport` in Task 5. + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/ClientConfigurationTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +@Suite struct ClientConfigurationTests { + @Test func authProviderSuppliesHeaders() async throws { + let auth = AuthProvider { ["Authorization": "Bearer t", "apikey": "k"] } + let headers = try await auth.headers() + #expect(headers["Authorization"] == "Bearer t") + #expect(headers["apikey"] == "k") + } + + @Test func configHoldsBaseURLAndDefaults() { + let config = ClientConfiguration(baseURL: URL(string: "https://x.supabase.co/storage/v1")!) + #expect(config.baseURL.absoluteString == "https://x.supabase.co/storage/v1") + #expect(config.sessionKind == .foreground) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter ClientConfigurationTests` +Expected: FAIL — `AuthProvider`/`ClientConfiguration` do not exist. + +- [ ] **Step 3: Implement `AuthProvider`** — `Sources/SupabaseRuntime/AuthProvider.swift`: + +```swift +import Foundation + +/// Supplies auth headers per request; async so token refresh fits. +public struct AuthProvider: Sendable { + private let provider: @Sendable () async throws -> [String: String] + public init(_ provider: @escaping @Sendable () async throws -> [String: String]) { self.provider = provider } + public func headers() async throws -> [String: String] { try await provider() } + + /// No auth headers. + public static let none = AuthProvider { [:] } +} +``` + +- [ ] **Step 4: Implement `ClientConfiguration`** — `Sources/SupabaseRuntime/ClientConfiguration.swift`: + +```swift +import Foundation + +public enum SessionKind: Sendable, Equatable { + case foreground + case background(identifier: String) +} + +public struct ClientConfiguration: Sendable { + public var baseURL: URL + public var defaultHeaders: [String: String] + public var auth: AuthProvider + public var encoder: JSONEncoder + public var decoder: JSONDecoder + public var sessionKind: SessionKind + /// Maps a non-2xx (body, head) to a typed error; returns nil to fall back to `TransportError.http`. + public var errorMapper: @Sendable (Data, HTTPResponseHead) -> (any Error)? + + public init( + baseURL: URL, + defaultHeaders: [String: String] = [:], + auth: AuthProvider = .none, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder(), + sessionKind: SessionKind = .foreground, + errorMapper: @escaping @Sendable (Data, HTTPResponseHead) -> (any Error)? = { _, _ in nil } + ) { + self.baseURL = baseURL + self.defaultHeaders = defaultHeaders + self.auth = auth + self.encoder = encoder + self.decoder = decoder + self.sessionKind = sessionKind + self.errorMapper = errorMapper + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter ClientConfigurationTests` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): add ClientConfiguration and AuthProvider" +``` + +--- + +### Task 5: URLSessionTransport — buffered `send` over native async URLSession + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift` +- Create: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/Support/StubURLProtocol.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportSendTests.swift` + +**Interfaces:** +- Consumes: `ClientConfiguration`, `HTTPRequest`, `Transport`, `TransportError`, `HTTPResponseHead` (Tasks 1-4). +- Produces: `URLSessionTransport` — an `actor` conforming to `Transport`; `init(configuration:urlSession:)` where `urlSession` is injectable for tests. Implements the three `send` overloads now (upload/download/stream land in Tasks 6-7; stub them to `fatalError("implemented in Task 6/7")` until then so the type compiles — replace in those tasks). + +- [ ] **Step 1: Write the test stub helper** — `Tests/SupabaseRuntimeTests/Support/StubURLProtocol.swift`: + +```swift +import Foundation + +/// A URLProtocol that returns a canned response, so transport tests need no network. +final class StubURLProtocol: URLProtocol, @unchecked Sendable { + struct Stub: Sendable { var status: Int; var headers: [String: String]; var body: Data } + nonisolated(unsafe) static var stub: Stub = .init(status: 200, headers: [:], body: Data()) + nonisolated(unsafe) static var lastRequest: URLRequest? + + static func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: config) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + StubURLProtocol.lastRequest = request + let stub = StubURLProtocol.stub + let response = HTTPURLResponse(url: request.url!, statusCode: stub.status, httpVersion: "HTTP/1.1", headerFields: stub.headers)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} +``` + +- [ ] **Step 2: Write the failing test** — `Tests/SupabaseRuntimeTests/URLSessionTransportSendTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct Bucket: Codable, Equatable, Sendable { let id: String } +private struct StorageError: Error, Equatable { let message: String } + +@Suite struct URLSessionTransportSendTests { + func makeTransport(errorMapper: (@Sendable (Data, HTTPResponseHead) -> (any Error)?)? = nil) -> URLSessionTransport { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/storage/v1")!) + if let errorMapper { config.errorMapper = errorMapper } + config.auth = AuthProvider { ["apikey": "k"] } + return URLSessionTransport(configuration: config, urlSession: StubURLProtocol.session()) + } + + @Test func sendDecodes2xx() async throws { + StubURLProtocol.stub = .init(status: 200, headers: ["Content-Type": "application/json"], body: #"{"id":"abc"}"#.data(using: .utf8)!) + let bucket: Bucket = try await makeTransport().send(HTTPRequest(method: .get, path: "/bucket/abc")) + #expect(bucket == Bucket(id: "abc")) + } + + @Test func sendBuildsURLWithBaseAndAuthHeader() async throws { + StubURLProtocol.stub = .init(status: 200, headers: [:], body: #"{"id":"x"}"#.data(using: .utf8)!) + let _: Bucket = try await makeTransport().send(HTTPRequest(method: .get, path: "/bucket/x", query: [URLQueryItem(name: "limit", value: "10")])) + let req = try #require(StubURLProtocol.lastRequest) + #expect(req.url?.absoluteString == "https://x.test/storage/v1/bucket/x?limit=10") + #expect(req.value(forHTTPHeaderField: "apikey") == "k") + } + + @Test func sendMapsNon2xxToTypedError() async { + StubURLProtocol.stub = .init(status: 400, headers: [:], body: #"{"message":"bad"}"#.data(using: .utf8)!) + let transport = makeTransport { data, _ in + (try? JSONDecoder().decode([String: String].self, from: data)["message"]).map { StorageError(message: $0) } ?? nil + } + await #expect(throws: StorageError(message: "bad")) { + let _: Bucket = try await transport.send(HTTPRequest(method: .get, path: "/bucket/x")) + } + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportSendTests` +Expected: FAIL — `URLSessionTransport` does not exist. + +- [ ] **Step 4: Implement `URLSessionTransport`** — `Sources/SupabaseRuntime/URLSessionTransport.swift`: + +```swift +import Foundation + +public actor URLSessionTransport: Transport { + let configuration: ClientConfiguration + let urlSession: URLSession + + public init(configuration: ClientConfiguration, urlSession: URLSession? = nil) { + self.configuration = configuration + if let urlSession { + self.urlSession = urlSession + } else { + switch configuration.sessionKind { + case .foreground: + self.urlSession = URLSession(configuration: .default) + case .background(let identifier): + self.urlSession = URLSession(configuration: .background(withIdentifier: identifier)) + } + } + } + + // MARK: Request building + + func makeURLRequest(_ request: HTTPRequest) async throws -> URLRequest { + var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false)! + components.path += request.path + if !request.query.isEmpty { components.queryItems = request.query } + var urlRequest = URLRequest(url: components.url!) + urlRequest.httpMethod = request.method.rawValue.uppercased() + for (k, v) in configuration.defaultHeaders { urlRequest.setValue(v, forHTTPHeaderField: k) } + for (k, v) in try await configuration.auth.headers() { urlRequest.setValue(v, forHTTPHeaderField: k) } + for (k, v) in request.headers { urlRequest.setValue(v, forHTTPHeaderField: k) } + return urlRequest + } + + func head(from response: URLResponse) -> HTTPResponseHead { + let http = response as? HTTPURLResponse + let headers = (http?.allHeaderFields as? [String: String]) ?? [:] + return HTTPResponseHead(status: http?.statusCode ?? 0, headers: headers) + } + + /// Throws a typed/`TransportError` for non-2xx; returns the data for 2xx. + func validate(_ data: Data, _ response: URLResponse) throws -> Data { + let head = head(from: response) + guard (200..<300).contains(head.status) else { + if let mapped = configuration.errorMapper(data, head) { throw mapped } + throw TransportError.http(status: head.status, body: data, head: head) + } + return data + } + + // MARK: send + + public func send(_ request: HTTPRequest) async throws -> R { + let urlRequest = try await makeURLRequest(request) + let (data, response) = try await urlSession.data(for: urlRequest) + let body = try validate(data, response) + do { return try configuration.decoder.decode(R.self, from: body) } + catch { throw TransportError.decoding(error) } + } + + public func send(_ request: HTTPRequest, body: B) async throws -> R { + var urlRequest = try await makeURLRequest(request) + urlRequest.httpBody = try configuration.encoder.encode(body) + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let (data, response) = try await urlSession.data(for: urlRequest) + let validated = try validate(data, response) + do { return try configuration.decoder.decode(R.self, from: validated) } + catch { throw TransportError.decoding(error) } + } + + public func send(_ request: HTTPRequest) async throws { + let urlRequest = try await makeURLRequest(request) + let (data, response) = try await urlSession.data(for: urlRequest) + _ = try validate(data, response) + } + + // MARK: streaming — implemented in Tasks 6-7 + + public nonisolated func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + fatalError("implemented in Task 6") + } + public nonisolated func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask { + fatalError("implemented in Task 6") + } + public func stream(_ request: HTTPRequest) async throws -> ResponseStream { + fatalError("implemented in Task 7") + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportSendTests` +Expected: PASS (all three cases). If query-ordering makes the URL assertion flaky, assert on `components` membership instead of the exact string. + +- [ ] **Step 6: Run the full suite and commit** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test` +Expected: all pass. + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): URLSessionTransport send over native async URLSession" +``` + +--- + +### Task 6: URLSessionTransport — streaming upload/download with progress + +**Files:** +- Create: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/SessionDelegate.swift` +- Modify: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift` (replace the `upload`/`download` stubs) +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportTransferTests.swift` + +**Interfaces:** +- Consumes: everything from Task 5. +- Produces: real `upload`/`download` returning `TransferTask`. A `SessionDelegate` (`NSObject`, `URLSessionDataDelegate`/`URLSessionTaskDelegate`/`URLSessionDownloadDelegate`) that bridges `didSendBodyData` / `didWriteData` into an `AsyncStream` continuation and completion into the task's result. + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/URLSessionTransportTransferTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct UploadResult: Codable, Equatable, Sendable { let key: String } + +@Suite struct URLSessionTransportTransferTests { + func makeTransport() -> URLSessionTransport { + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/storage/v1")!) + config.auth = AuthProvider { ["apikey": "k"] } + return URLSessionTransport(configuration: config, urlSession: StubURLProtocol.session()) + } + + @Test func uploadFromDataReturnsDecodedValue() async throws { + StubURLProtocol.stub = .init(status: 200, headers: [:], body: #"{"key":"folder/cat.png"}"#.data(using: .utf8)!) + let task: TransferTask = makeTransport() + .upload(HTTPRequest(method: .post, path: "/object/avatars/cat.png"), from: .data(Data([0x1, 0x2]))) + // Drain progress (may be empty under the stub) so the stream completes. + for await _ in task.progress {} + let result = try await task.value() + #expect(result == UploadResult(key: "folder/cat.png")) + } + + @Test func downloadWritesToFile() async throws { + StubURLProtocol.stub = .init(status: 200, headers: [:], body: Data([0xA, 0xB, 0xC])) + let dest = FileManager.default.temporaryDirectory.appendingPathComponent("dl-\(UUID().uuidString).bin") + defer { try? FileManager.default.removeItem(at: dest) } + let task = makeTransport().download(HTTPRequest(method: .get, path: "/object/avatars/cat.png"), toFile: dest) + for await _ in task.progress {} + try await task.value() + #expect(try Data(contentsOf: dest) == Data([0xA, 0xB, 0xC])) + } +} +``` + +Note: a `URLProtocol` stub does not emit realistic incremental `didSendBodyData`/`didWriteData` callbacks, so these tests verify the *result and file-writing* paths and that the progress stream terminates. Byte-accurate progress is covered by an integration test against a real server, tracked in the spec's risks — do not fake progress to make a unit test "verify" it. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportTransferTests` +Expected: FAIL — `upload`/`download` still `fatalError`. + +- [ ] **Step 3: Implement `SessionDelegate`** — `Sources/SupabaseRuntime/SessionDelegate.swift`: + +```swift +import Foundation + +/// Bridges URLSession task callbacks into progress streams + completion continuations. +/// One delegate instance per session; keyed by task identifier. +final class SessionDelegate: NSObject, URLSessionDataDelegate, URLSessionDownloadDelegate, @unchecked Sendable { + private let lock = NSLock() + private var progress: [Int: AsyncStream.Continuation] = [:] + private var dataCompletions: [Int: (Result) -> Void] = [:] + private var fileCompletions: [Int: (Result) -> Void] = [:] + private var buffers: [Int: Data] = [:] + private var responses: [Int: URLResponse] = [:] + + func registerProgress(_ id: Int, _ cont: AsyncStream.Continuation) { + lock.withLock { progress[id] = cont } + } + func onData(_ id: Int, _ completion: @escaping (Result) -> Void) { + lock.withLock { dataCompletions[id] = completion } + } + func onFile(_ id: Int, _ completion: @escaping (Result) -> Void) { + lock.withLock { fileCompletions[id] = completion } + } + + // Upload progress. + func urlSession(_ s: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + let total = totalBytesExpectedToSend > 0 ? totalBytesExpectedToSend : nil + lock.withLock { progress[task.taskIdentifier] }?.yield(TransferProgress(completed: totalBytesSent, total: total)) + } + + // Buffered data (for upload responses). + func urlSession(_ s: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + lock.withLock { buffers[dataTask.taskIdentifier, default: Data()].append(data) } + } + func urlSession(_ s: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + lock.withLock { responses[dataTask.taskIdentifier] = response } + completionHandler(.allow) + } + + // Download progress + file. + func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let total = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : nil + lock.withLock { progress[downloadTask.taskIdentifier] }?.yield(TransferProgress(completed: totalBytesWritten, total: total)) + } + func urlSession(_ s: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + // Move synchronously inside the delegate callback (the temp file is deleted when this returns). + let id = downloadTask.taskIdentifier + let completion = lock.withLock { fileCompletions[id] } + completion?(.success(location)) + } + + func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + let id = task.taskIdentifier + let (cont, dataDone, buffer, response) = lock.withLock { + (progress[id], dataCompletions[id], buffers[id] ?? Data(), responses[id]) + } + cont?.finish() + if let error { + dataDone?(.failure(error)) + } else if let dataDone { + dataDone(.success(buffer)) + } + lock.withLock { + progress[id] = nil; dataCompletions[id] = nil; fileCompletions[id] = nil + buffers[id] = nil; responses[id] = nil + } + _ = response + } +} +``` + +- [ ] **Step 4: Wire the delegate into `URLSessionTransport`** — replace the `upload`/`download` stubs and adjust `init` to own a `SessionDelegate`. In `URLSessionTransport.swift`: + +Change `urlSession` to `nonisolated(unsafe)` (URLSession is thread-safe) so the `nonisolated` `upload`/`download` can read it, and add a `nonisolated let delegate`. This replaces the `let urlSession: URLSession` declaration from Task 5. The property block + `init` become: + +```swift + let configuration: ClientConfiguration + nonisolated(unsafe) let urlSession: URLSession // URLSession is thread-safe; readable from nonisolated upload/download + nonisolated let delegate = SessionDelegate() + + public init(configuration: ClientConfiguration, urlSession: URLSession? = nil) { + self.configuration = configuration + if let urlSession { + self.urlSession = urlSession + } else { + let cfg: URLSessionConfiguration + switch configuration.sessionKind { + case .foreground: cfg = .default + case .background(let id): cfg = .background(withIdentifier: id) + } + self.urlSession = URLSession(configuration: cfg, delegate: delegate, delegateQueue: nil) + } + } +``` + +Replace the `upload`/`download` `fatalError` stubs with: + +```swift + public nonisolated func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + let (stream, cont) = AsyncStream.makeStream() + let session = urlSession + let delegate = self.delegate + let decoder = configuration.decoder + let task = Task { [self] () -> R in + let urlRequest = try await makeURLRequest(request) + let sessionTask: URLSessionUploadTask + switch source { + case .file(let url): sessionTask = session.uploadTask(with: urlRequest, fromFile: url) + case .data(let data): sessionTask = session.uploadTask(with: urlRequest, from: data) + } + delegate.registerProgress(sessionTask.taskIdentifier, cont) + let data: Data = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { c in + delegate.onData(sessionTask.taskIdentifier) { c.resume(with: $0) } + sessionTask.resume() + } + } onCancel: { sessionTask.cancel() } + return try decoder.decode(R.self, from: data) + } + return TransferTask(progress: stream, value: { try await task.value }, cancel: { task.cancel() }) + } + + public nonisolated func download(_ request: HTTPRequest, toFile destination: URL) -> TransferTask { + let (stream, cont) = AsyncStream.makeStream() + let session = urlSession + let delegate = self.delegate + let task = Task { [self] () -> Void in + let urlRequest = try await makeURLRequest(request) + let sessionTask = session.downloadTask(with: urlRequest) + delegate.registerProgress(sessionTask.taskIdentifier, cont) + let tempURL: URL = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { c in + delegate.onFile(sessionTask.taskIdentifier) { c.resume(with: $0) } + sessionTask.resume() + } + } onCancel: { sessionTask.cancel() } + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: tempURL, to: destination) + } + return TransferTask(progress: stream, value: { try await task.value }, cancel: { task.cancel() }) + } +``` + +Note: `makeURLRequest` is `actor`-isolated, so the `Task` closures `await` it — correct. The `didFinishDownloadingTo` callback hands back the temp URL; the `download` closure moves it before the callback returns is not guaranteed, so move happens in the awaiting task — acceptable for the foreground case; the integration/background nuance is tracked in Task 8 / risks. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportTransferTests` +Expected: PASS. If the download temp-file move races the callback, capture the file inside `didFinishDownloadingTo` (copy to a stable temp path there) — note this and adjust. + +- [ ] **Step 6: Run the full suite and commit** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test` + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): streaming upload/download with progress" +``` + +--- + +### Task 7: URLSessionTransport — `stream()` for event-stream responses + +**Files:** +- Modify: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift` (replace the `stream` stub) +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/URLSessionTransportStreamTests.swift` + +**Interfaces:** +- Produces: real `stream(_:) async throws -> ResponseStream` using `URLSession.bytes(for:)`. + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/URLSessionTransportStreamTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +@Suite struct URLSessionTransportStreamTests { + @Test func streamYieldsBodyBytesAndHead() async throws { + StubURLProtocol.stub = .init(status: 200, headers: ["Content-Type": "text/event-stream"], body: Data([0x64, 0x61, 0x74, 0x61])) + var config = ClientConfiguration(baseURL: URL(string: "https://x.test/functions/v1")!) + config.auth = AuthProvider { ["apikey": "k"] } + let transport = URLSessionTransport(configuration: config, urlSession: StubURLProtocol.session()) + let response = try await transport.stream(HTTPRequest(method: .get, path: "/events")) + #expect(response.head.status == 200) + var bytes: [UInt8] = [] + for try await chunk in response.body { bytes.append(contentsOf: chunk) } + #expect(bytes == [0x64, 0x61, 0x74, 0x61]) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportStreamTests` +Expected: FAIL — `stream` still `fatalError`. + +- [ ] **Step 3: Implement `stream`** — replace the `stream` stub in `URLSessionTransport.swift`: + +```swift + public func stream(_ request: HTTPRequest) async throws -> ResponseStream { + let urlRequest = try await makeURLRequest(request) + let (bytes, response) = try await urlSession.bytes(for: urlRequest) + let responseHead = head(from: response) + guard (200..<300).contains(responseHead.status) else { + var collected = Data() + for try await byte in bytes { collected.append(byte) } + if let mapped = configuration.errorMapper(collected, responseHead) { throw mapped } + throw TransportError.http(status: responseHead.status, body: collected, head: responseHead) + } + let body = AsyncThrowingStream, any Error> { continuation in + let task = Task { + do { + var chunk = [UInt8]() + chunk.reserveCapacity(4096) + for try await byte in bytes { + chunk.append(byte) + if chunk.count >= 4096 { continuation.yield(ArraySlice(chunk)); chunk.removeAll(keepingCapacity: true) } + } + if !chunk.isEmpty { continuation.yield(ArraySlice(chunk)) } + continuation.finish() + } catch { continuation.finish(throwing: error) } + } + continuation.onTermination = { _ in task.cancel() } + } + return ResponseStream(head: responseHead, body: body) + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter URLSessionTransportStreamTests` +Expected: PASS. + +- [ ] **Step 5: Run the full suite and commit** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test` + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): stream() for event-stream responses" +``` + +--- + +### Task 8: Background sessions — `sessionKind` wiring, file-only guard, relaunch hook + +**Files:** +- Modify: `codegen/runtime/swift/SupabaseRuntime/Sources/SupabaseRuntime/URLSessionTransport.swift` +- Test: `codegen/runtime/swift/SupabaseRuntime/Tests/SupabaseRuntimeTests/BackgroundSessionTests.swift` + +**Interfaces:** +- Produces: `URLSessionTransport.isBackground` (Bool); a `backgroundRequiresFile` guard on `upload(from: .data)` when background; `handleBackgroundEvents(identifier:completionHandler:)` storing the system completion handler. + +- [ ] **Step 1: Write the failing test** — `Tests/SupabaseRuntimeTests/BackgroundSessionTests.swift`: + +```swift +import Foundation +import Testing +@testable import SupabaseRuntime + +private struct R: Codable, Sendable { let ok: Bool } + +@Suite struct BackgroundSessionTests { + @Test func backgroundUploadFromDataThrowsRequiresFile() async { + let config = ClientConfiguration(baseURL: URL(string: "https://x.test/v1")!, sessionKind: .background(identifier: "test.bg")) + let transport = URLSessionTransport(configuration: config) + let task: TransferTask = transport.upload(HTTPRequest(method: .post, path: "/object/b/k"), from: .data(Data([1]))) + await #expect(throws: TransportError.self) { _ = try await task.value() } + } + + @Test func handleBackgroundEventsStoresCompletion() async { + let config = ClientConfiguration(baseURL: URL(string: "https://x.test/v1")!, sessionKind: .background(identifier: "test.bg2")) + let transport = URLSessionTransport(configuration: config) + let box = CompletionBox() + await transport.handleBackgroundEvents(identifier: "test.bg2") { box.fire() } + let stored = await transport.consumeBackgroundCompletion(identifier: "test.bg2") + stored?() + #expect(box.fired == true) + } +} + +private final class CompletionBox: @unchecked Sendable { + private let lock = NSLock(); private(set) var fired = false + func fire() { lock.withLock { fired = true } } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter BackgroundSessionTests` +Expected: FAIL — the guard and `handleBackgroundEvents` do not exist. + +- [ ] **Step 3: Implement** — in `URLSessionTransport.swift`: + +Add background state + helpers: + +```swift + var isBackground: Bool { + if case .background = configuration.sessionKind { return true } + return false + } + + private var backgroundCompletions: [String: @Sendable () -> Void] = [:] + + /// Call from the app's `handleEventsForBackgroundURLSession` / SwiftUI `backgroundTask`. + public func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { + backgroundCompletions[identifier] = completionHandler + } + + /// The transport calls this after the session reports it has finished delivering background events. + public func consumeBackgroundCompletion(identifier: String) -> (@Sendable () -> Void)? { + defer { backgroundCompletions[identifier] = nil } + return backgroundCompletions[identifier] + } +``` + +Guard in-memory uploads for background — at the start of the `upload` `Task` closure, before creating the session task: + +```swift + if isBackgroundSession, case .data = source { + throw TransportError.backgroundRequiresFile + } +``` + +Because `upload` is `nonisolated`, capture the background flag without hopping the actor — compute it from `configuration.sessionKind` (a `Sendable` value) captured into the closure: + +```swift + public nonisolated func upload(_ request: HTTPRequest, from source: UploadSource) -> TransferTask { + let isBackgroundSession: Bool = { if case .background = configuration.sessionKind { return true } else { return false } }() + // ... existing body, with the guard above as the first statement inside the Task closure ... + } +``` + +(Adjust the existing Task 6 `upload` to add `let isBackgroundSession = ...` before the `Task {` and the guard as the first line inside it.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test --filter BackgroundSessionTests` +Expected: PASS. + +- [ ] **Step 5: Run the full suite, build for a device-class platform sanity check, commit** + +Run: `cd codegen/runtime/swift/SupabaseRuntime && swift test && swift build` +Expected: all pass. + +```bash +git add codegen/runtime/swift/SupabaseRuntime +git commit -m "feat(runtime): background session selection, file-only guard, relaunch hook" +``` + +Note: full background-transfer behavior (suspension, system relaunch, delivery via the stored completion handler) requires on-device/integration testing and is out of scope for unit tests — tracked in the spec's risks. This task delivers the configuration selection, the file-only guard, and the relaunch-handler plumbing. + +--- + +## Out of scope (Plan B and later) + +- The lean `templates/swift/` pack + `codegen.yaml` changes that generate `StorageClient` against this runtime, regenerate Storage, and build/drift-guard it (Plan B). +- The normalizer naming/dedup batch from the audit. +- The hand-written ergonomic surface. + +## Self-review notes + +- **Spec coverage:** `Transport` contract (spec §5) → Tasks 1-3; `ClientConfiguration`/`AuthProvider` (§5/§6) → Task 4; `send` over native async (§6) → Task 5; streaming upload/download + progress (§5/§6) → Task 6; `stream()` event streams (§5/§6) → Task 7; background sessions + file-only + relaunch hook (§6) → Task 8; `MockTransport` (§6) → Task 3; typed errors (§5) → Tasks 1+5. Template/codegen work (§7) and decomposition (§8) are Plan B, explicitly out of scope here. +- **Type consistency:** `Transport` method signatures match across Task 3 (protocol), Task 3 (`MockTransport`), and Tasks 5-8 (`URLSessionTransport`). `TransferTask(progress:value:cancel:)`, `ClientConfiguration(baseURL:…errorMapper:)`, `AuthProvider(_:)`/`.headers()`, `HTTPRequest(method:path:query:headers:)`, and `RequestPath` `\(param:)` are used identically everywhere. +- **No placeholders:** the only deliberate inter-task stubs are `URLSessionTransport.upload/download/stream` in Task 5 (marked `fatalError("implemented in Task 6/7")`), explicitly replaced in Tasks 6-7 — every other step ships complete code. +- **Honest test limits:** Task 6 (byte-accurate progress) and Task 8 (full background lifecycle) note what unit tests can't cover and defer it to integration testing rather than faking verification. diff --git a/schema/capability-matrix.schema.json b/schema/capability-matrix.schema.json index 31e8056..c255f03 100644 --- a/schema/capability-matrix.schema.json +++ b/schema/capability-matrix.schema.json @@ -40,7 +40,17 @@ }, "name": { "type": "string", "minLength": 1 }, "description": { "type": "string", "minLength": 1 }, - "group": { "type": "string", "minLength": 1 } + "group": { "type": "string", "minLength": 1 }, + "binding": { "$ref": "#/$defs/binding" } + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": ["spec", "operationId"], + "properties": { + "spec": { "type": "string", "minLength": 1, "description": "Spec id declared in codegen.yaml" }, + "operationId": { "type": "string", "minLength": 1, "description": "OpenAPI operationId this feature maps to" } } } } diff --git a/schema/codegen.schema.json b/schema/codegen.schema.json new file mode 100644 index 0000000..0acfa57 --- /dev/null +++ b/schema/codegen.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/sdk/codegen.schema.json", + "title": "Supabase SDK codegen config", + "type": "object", + "additionalProperties": false, + "required": ["engine", "specs", "languages"], + "properties": { + "engine": { + "type": "object", + "additionalProperties": false, + "required": ["tool", "version"], + "properties": { + "tool": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + }, + "specs": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["source", "version"], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 } + } + } + }, + "languages": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["generator"], + "properties": { + "generator": { "type": "string", "minLength": 1 }, + "templates": { "type": "string", "minLength": 1 }, + "generatorProperties": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["spec", "language", "output"], + "properties": { + "spec": { "type": "string", "minLength": 1 }, + "language": { "type": "string", "minLength": 1 }, + "output": { "type": "string", "minLength": 1 } + } + } + } + } +} diff --git a/schema/conformance.schema.json b/schema/conformance.schema.json new file mode 100644 index 0000000..ffab3b7 --- /dev/null +++ b/schema/conformance.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/sdk/conformance.schema.json", + "title": "Supabase SDK conformance vector file", + "type": "object", + "additionalProperties": false, + "required": ["feature", "cases"], + "properties": { + "feature": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*\\.[a-z0-9_]+\\.[a-z0-9_]+$" + }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "input", "expected"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "input": {}, + "expected": {} + } + } + } + } +} diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index a71b2f0..a675243 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -12,7 +12,10 @@ "report": "tsx src/cli.ts report", "test": "vitest run", "typecheck": "tsc --noEmit", - "build-site": "tsx src/generate-site.ts" + "build-site": "tsx src/generate-site.ts", + "normalize": "tsx src/normalize-cli.ts", + "generate": "tsx src/generate-cli.ts", + "generate:check": "npm run normalize && npm run generate && git -C ../.. diff --exit-code -- codegen/specs/storage.normalized.json" }, "dependencies": { "@octokit/rest": "^21.0.2", diff --git a/scripts/capability-matrix/src/bindings.ts b/scripts/capability-matrix/src/bindings.ts new file mode 100644 index 0000000..1ff1d92 --- /dev/null +++ b/scripts/capability-matrix/src/bindings.ts @@ -0,0 +1,68 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { CodegenConfig } from "./codegen"; +import type { Finding, LoadedArea } from "./types"; + +export function checkBindings(loaded: LoadedArea[], config: CodegenConfig): Finding[] { + const findings: Finding[] = []; + for (const { file, area } of loaded) { + for (const feature of area?.features ?? []) { + const binding = feature.binding; + if (!binding) continue; + if (!config.specs[binding.spec]) { + findings.push({ + level: "error", + file, + message: `feature "${feature.id}" binds to unknown spec "${binding.spec}" (not declared in codegen config)`, + }); + } + } + } + return findings; +} + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options"]; + +function specOperationIds(file: string): Set { + const doc = JSON.parse(readFileSync(file, "utf8")) as Record; + const ids = new Set(); + for (const item of Object.values(doc.paths ?? {})) { + for (const m of HTTP_METHODS) { + const op = (item as any)?.[m]; + if (op?.operationId) ids.add(op.operationId); + } + } + return ids; +} + +/** + * Verifies each feature binding's operationId exists in its referenced spec. + * `baseDir` is the directory codegen.yaml lives in (spec.source is relative to it). + */ +export function checkBindingOperations(loaded: LoadedArea[], config: CodegenConfig, baseDir: string): Finding[] { + const findings: Finding[] = []; + const cache = new Map | null>(); + for (const { file, area } of loaded) { + for (const feature of area?.features ?? []) { + const binding = feature.binding; + if (!binding) continue; + const spec = config.specs[binding.spec]; + if (!spec) continue; // unknown spec already reported by checkBindings + const specFile = resolve(baseDir, spec.source); + if (!cache.has(specFile)) { + try { + cache.set(specFile, specOperationIds(specFile)); + } catch (e) { + findings.push({ level: "error", file, message: `cannot read spec "${spec.source}" for operationId check: ${(e as Error).message}` }); + cache.set(specFile, null); + } + } + const ids = cache.get(specFile); + if (!ids) continue; // null = spec unreadable (already reported once); empty Set is truthy and still checked + if (!ids.has(binding.operationId)) { + findings.push({ level: "error", file, message: `feature "${feature.id}" binds to operationId "${binding.operationId}" not present in spec "${binding.spec}"` }); + } + } + } + return findings; +} diff --git a/scripts/capability-matrix/src/cli.ts b/scripts/capability-matrix/src/cli.ts index caab446..77f11ab 100644 --- a/scripts/capability-matrix/src/cli.ts +++ b/scripts/capability-matrix/src/cli.ts @@ -1,9 +1,12 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import { loadAreas } from "./load"; import { checkSchema } from "./schema"; import { checkStructural, checkSpecs } from "./structural"; +import { loadCodegenConfig, checkCodegenConfig } from "./codegen"; +import { checkBindings, checkBindingOperations } from "./bindings"; +import { checkConformance } from "./conformance"; import { computeParity, type ParityReport } from "./report"; import type { Finding } from "./types"; @@ -13,6 +16,10 @@ export interface RunOptions { schema: object; specsDir?: string; changedFiles?: string[]; + codegenConfigPath?: string; + codegenSchema?: object; + conformanceDir?: string; + conformanceSchema?: object; } export interface RunResult { @@ -32,11 +39,28 @@ export async function run(opts: RunOptions): Promise { findings.push(...checkSchema(areas, opts.schema)); findings.push(...checkStructural(areas)); + const knownIds = new Set(areas.flatMap((a) => a.area.features.map((f) => f.id))); + if (opts.specsDir) { - const knownIds = new Set(areas.flatMap((a) => a.area.features.map((f) => f.id))); findings.push(...checkSpecs(opts.specsDir, knownIds)); } + // Optional codegen contract: absent codegen.yaml / conformance dir = opt-out, not an error. + if (opts.codegenConfigPath && opts.codegenSchema && existsSync(opts.codegenConfigPath)) { + const { config, findings: configFindings } = loadCodegenConfig(opts.codegenConfigPath); + findings.push(...configFindings); + if (config) { + findings.push(...checkCodegenConfig(config, opts.codegenSchema, opts.codegenConfigPath)); + findings.push(...checkBindings(areas, config)); + findings.push(...checkBindingOperations(areas, config, dirname(opts.codegenConfigPath))); + } + } + + // Optional codegen contract: absent codegen.yaml / conformance dir = opt-out, not an error. + if (opts.conformanceDir && opts.conformanceSchema) { + findings.push(...checkConformance(opts.conformanceDir, knownIds, opts.conformanceSchema)); + } + const errorCount = findings.filter((f) => f.level === "error").length; return { findings, errorCount }; } @@ -53,11 +77,18 @@ async function main(): Promise { const positionals = argv.slice(1).filter((a) => !a.startsWith("--")); const schema = JSON.parse(readFileSync(join(root, "schema", "capability-matrix.schema.json"), "utf8")); + // Schema files are always committed to the repo; the optional inputs they validate (codegen.yaml, conformance/) may be absent. + const codegenSchema = JSON.parse(readFileSync(join(root, "schema", "codegen.schema.json"), "utf8")); + const conformanceSchema = JSON.parse(readFileSync(join(root, "schema", "conformance.schema.json"), "utf8")); const result = await run({ mode, capabilitiesDir: join(root, "capabilities"), specsDir: join(root, "specs"), schema, + codegenConfigPath: join(root, "codegen.yaml"), + codegenSchema, + conformanceDir: join(root, "conformance"), + conformanceSchema, changedFiles: positionals.length > 0 ? positionals : undefined, }); diff --git a/scripts/capability-matrix/src/codegen.ts b/scripts/capability-matrix/src/codegen.ts new file mode 100644 index 0000000..debebdb --- /dev/null +++ b/scripts/capability-matrix/src/codegen.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; +import { compileSchema } from "./schema"; +import type { Finding } from "./types"; + +export interface SpecSource { + source: string; + version: string; +} + +export interface LanguageConfig { + generator: string; + templates?: string; + generatorProperties?: Record; +} + +/** + * A generation target as declared in codegen.yaml. `output` is the YAML-facing + * name for the destination directory; the generate CLI maps it to the `outDir` + * field of `buildGenerateArgs`'s `GenerateTarget`. + */ +export interface GenerateTargetConfig { + spec: string; + language: string; + output: string; +} + +export interface CodegenConfig { + engine: { tool: string; version: string }; + specs: Record; + languages: Record; + targets?: GenerateTargetConfig[]; +} + +export function loadCodegenConfig(file: string): { config?: CodegenConfig; findings: Finding[] } { + try { + const config = parse(readFileSync(file, "utf8")) as CodegenConfig; + return { config, findings: [] }; + } catch (e) { + return { findings: [{ level: "error", file, message: `codegen config parse error: ${(e as Error).message}` }] }; + } +} + +export function checkCodegenConfig(config: unknown, schema: object, file = "codegen.yaml"): Finding[] { + const validate = compileSchema(schema); + if (validate(config)) return []; + return (validate.errors ?? []).map((err) => ({ + level: "error" as const, + file, + message: `codegen schema: ${err.instancePath || "/"} ${err.message ?? "invalid"}`, + })); +} diff --git a/scripts/capability-matrix/src/conformance.ts b/scripts/capability-matrix/src/conformance.ts new file mode 100644 index 0000000..5607e84 --- /dev/null +++ b/scripts/capability-matrix/src/conformance.ts @@ -0,0 +1,49 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { compileSchema } from "./schema"; +import type { Finding } from "./types"; + +function collectFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectFiles(p)); + else if (entry.name.endsWith(".yaml")) out.push(p); + } + return out; +} + +export function checkConformance(dir: string, knownIds: Set, schema: object): Finding[] { + const findings: Finding[] = []; + const validate = compileSchema(schema); + + let files: string[]; + try { + files = collectFiles(dir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "ENOENT") return findings; // conformance dir absent + throw e; + } + + for (const file of files) { + let doc: unknown; + try { + doc = parse(readFileSync(file, "utf8")); + } catch (e) { + findings.push({ level: "error", file, message: `YAML parse error: ${(e as Error).message}` }); + continue; + } + if (!validate(doc)) { + for (const err of validate.errors ?? []) { + findings.push({ level: "error", file, message: `conformance: ${err.instancePath || "/"} ${err.message ?? "invalid"}` }); + } + continue; + } + const feature = (doc as { feature: string }).feature; + if (!knownIds.has(feature)) { + findings.push({ level: "error", file, message: `conformance vector references unknown feature id "${feature}"` }); + } + } + return findings; +} diff --git a/scripts/capability-matrix/src/generate-cli.ts b/scripts/capability-matrix/src/generate-cli.ts new file mode 100644 index 0000000..b6f82a4 --- /dev/null +++ b/scripts/capability-matrix/src/generate-cli.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { loadCodegenConfig } from "./codegen"; +import { runGenerate } from "./generate"; + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function main(): void { + const root = repoRoot(); + const { config, findings } = loadCodegenConfig(join(root, "codegen.yaml")); + if (!config) { + for (const f of findings) console.error(`ERROR ${f.file}: ${f.message}`); + process.exit(1); + } + const targets = config.targets ?? []; + if (targets.length === 0) { + console.log("no targets declared in codegen.yaml"); + return; + } + for (const t of targets) { + console.log(`generating ${t.spec} -> ${t.language} into ${t.output}`); + // codegen.yaml paths are repo-root relative; run with cwd=root so they resolve. + runGenerate(config, { spec: t.spec, language: t.language, outDir: t.output }, { cwd: root }); + } +} + +main(); diff --git a/scripts/capability-matrix/src/generate.ts b/scripts/capability-matrix/src/generate.ts new file mode 100644 index 0000000..a109a30 --- /dev/null +++ b/scripts/capability-matrix/src/generate.ts @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process"; +import type { CodegenConfig } from "./codegen"; + +export interface GenerateTarget { + spec: string; + language: string; + outDir: string; +} + +/** + * Builds the argv for `openapi-generator-cli generate` from the codegen config + * and a target. Pure function — the engine version pin is applied by the + * openapi-generator-cli toolchain (openapitools.json), not here. + */ +export function buildGenerateArgs(config: CodegenConfig, target: GenerateTarget): string[] { + const spec = config.specs[target.spec]; + if (!spec) throw new Error(`unknown spec "${target.spec}" (not declared in codegen config)`); + const lang = config.languages[target.language]; + if (!lang) throw new Error(`unknown language "${target.language}" (not declared in codegen config)`); + + const args = [ + "generate", + "--input-spec", spec.source, + "--generator-name", lang.generator, + "--output", target.outDir, + ]; + if (lang.templates) { + args.push("--template-dir", lang.templates); + } + + const extra = lang.generatorProperties; + if (extra && Object.keys(extra).length > 0) { + const pairs = Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(","); + args.push(`--additional-properties=${pairs}`); + } + + return args; +} + +export interface RunGenerateOptions { + cwd: string; + bin?: string; + stdio?: "inherit" | "pipe"; +} + +/** Spawns openapi-generator with the args from buildGenerateArgs. Throws on non-zero exit. */ +export function runGenerate(config: CodegenConfig, target: GenerateTarget, opts: RunGenerateOptions): void { + const args = buildGenerateArgs(config, target); + const bin = opts.bin ?? "openapi-generator"; + const res = spawnSync(bin, args, { cwd: opts.cwd, stdio: opts.stdio ?? "inherit" }); + if (res.error) throw new Error(`failed to spawn ${bin}: ${res.error.message}`); + if (res.status !== 0) throw new Error(`${bin} ${args.join(" ")} exited with status ${res.status}`); +} diff --git a/scripts/capability-matrix/src/normalize-cli.ts b/scripts/capability-matrix/src/normalize-cli.ts new file mode 100644 index 0000000..e19596b --- /dev/null +++ b/scripts/capability-matrix/src/normalize-cli.ts @@ -0,0 +1,33 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { normalizeSpec, findUnmatchedOverrides, type NormalizeOptions } from "./normalize"; + +function repoRoot(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", ".."); +} + +function main(): void { + const root = repoRoot(); + // Usage: tsx src/normalize-cli.ts (defaults target the Storage pilot) + const argv = process.argv.slice(2); + const input = resolve(root, argv[0] ?? "codegen/specs/storage.upstream.json"); + const output = resolve(root, argv[1] ?? "codegen/specs/storage.normalized.json"); + const configPath = resolve(root, argv[2] ?? "codegen/normalize/storage.json"); + + const spec = JSON.parse(readFileSync(input, "utf8")); + const options = JSON.parse(readFileSync(configPath, "utf8")) as NormalizeOptions; + normalizeSpec(spec, options); + const unmatched = [ + ...findUnmatchedOverrides(spec, options.operationIdOverrides ?? {}), + ...findUnmatchedOverrides(spec, options.requestBodyInjections ?? {}), + ]; + if (unmatched.length > 0) { + throw new Error(`override/injection keys match no operation (check method + exact path, incl. trailing slash): ${unmatched.join(", ")}`); + } + writeFileSync(output, JSON.stringify(spec, null, 2) + "\n"); + console.log(`normalized ${input} -> ${output}`); +} + +main(); diff --git a/scripts/capability-matrix/src/normalize.ts b/scripts/capability-matrix/src/normalize.ts new file mode 100644 index 0000000..270c353 --- /dev/null +++ b/scripts/capability-matrix/src/normalize.ts @@ -0,0 +1,291 @@ +// Normalizes an upstream OpenAPI document in place so it generates clean, +// compilable client code. The document is loosely typed (OpenAPI is huge); +// we operate on the few shapes we care about. +type OpenApiDoc = Record; + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options"] as const; + +export interface NormalizeOptions { + wildcardParamName?: string; + schemaRenames?: Record; + operationIdOverrides?: Record; + requestBodyInjections?: Record; +} + +/** Replaces `{*}` path segments (Fastify wildcards) and their `*`-named path params. */ +export function renameWildcardParams(spec: OpenApiDoc, paramName = "objectPath"): OpenApiDoc { + const paths = spec.paths ?? {}; + for (const key of Object.keys(paths)) { + if (!key.includes("{*}")) continue; + const newKey = key.split("{*}").join(`{${paramName}}`); + const item = paths[key]; + const paramArrays = [item.parameters, ...HTTP_METHODS.map((m) => item[m]?.parameters)]; + for (const params of paramArrays) { + if (!Array.isArray(params)) continue; + for (const p of params) { + if (p && p.in === "path" && p.name === "*") p.name = paramName; + } + } + delete paths[key]; + paths[newKey] = item; + } + return spec; +} + +/** Renames component schemas and rewrites every `$ref` that pointed at them. */ +export function renameSchemas(spec: OpenApiDoc, renames: Record): OpenApiDoc { + const schemas = spec.components?.schemas ?? {}; + const refMap = new Map(); + for (const [oldName, newName] of Object.entries(renames)) { + if (oldName === newName) continue; + if (schemas[oldName] === undefined) continue; + schemas[newName] = schemas[oldName]; + delete schemas[oldName]; + refMap.set(`#/components/schemas/${oldName}`, `#/components/schemas/${newName}`); + } + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if (typeof node.$ref === "string" && refMap.has(node.$ref)) node.$ref = refMap.get(node.$ref)!; + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** Deterministic, unique-ish id from method + path (params rendered as `By `). */ +export function deriveOperationId(method: string, path: string): string { + const tokens: string[] = [method]; + for (const seg of path.split("/").filter(Boolean)) { + const m = seg.match(/^\{(.+)\}$/); + if (m) { + // Preserve the param name casing; prefix with "By" as a separate token so + // the loop capitalises "By" but leaves the param name intact. + const name = m[1]; + tokens.push("By"); + tokens.push(name); + } else { + tokens.push(seg); + } + } + // Capitalise first letter of each token (except the first, which is lowercased), + // keeping param names verbatim so `bucketId` stays `bucketId` rather than `bucketid`. + const result: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; + if (i === 0) { + result.push(t.toLowerCase()); + } else { + result.push(t[0].toUpperCase() + t.slice(1)); + } + } + return result.join(""); +} + +/** Sets `operationId` on every operation lacking one (override map wins; guarantees uniqueness). */ +export function injectOperationIds(spec: OpenApiDoc, overrides: Record = {}): OpenApiDoc { + const used = new Set(); + const paths = spec.paths ?? {}; + for (const path of Object.keys(paths)) { + for (const m of HTTP_METHODS) { + const op = paths[path]?.[m]; + if (op?.operationId) used.add(op.operationId); + } + } + for (const path of Object.keys(paths)) { + for (const m of HTTP_METHODS) { + const op = paths[path]?.[m]; + if (!op || op.operationId) continue; + const base = overrides[`${m.toUpperCase()} ${path}`] ?? deriveOperationId(m, path); + let id = base; + let n = 2; + while (used.has(id)) id = `${base}${n++}`; + op.operationId = id; + used.add(id); + } + } + return spec; +} + +/** Adds a requestBody to operations that lack one. Keyed "METHOD /path" (normalized path). */ +export function injectRequestBodies(spec: OpenApiDoc, injections: Record): OpenApiDoc { + const paths = spec.paths ?? {}; + for (const [key, body] of Object.entries(injections)) { + const sp = key.indexOf(" "); + if (sp < 0) continue; + const method = key.slice(0, sp).toLowerCase(); + const path = key.slice(sp + 1); + const op = paths[path]?.[method]; + if (op && op.requestBody === undefined) op.requestBody = body; + } + return spec; +} + +/** Returns override keys ("METHOD /path") that match no operation in the spec. */ +export function findUnmatchedOverrides(spec: OpenApiDoc, overrides: Record): string[] { + const paths = spec.paths ?? {}; + return Object.keys(overrides).filter((key) => { + const sp = key.indexOf(" "); + if (sp < 0) return true; + const method = key.slice(0, sp).toLowerCase(); + const path = key.slice(sp + 1); + return !paths[path]?.[method]; + }); +} + +/** + * Removes duplicate property names that differ only in case (e.g. `Id` vs `id`). + * Generators that map property names to camelCase can produce conflicting Swift/Kotlin + * declarations when the spec has both. The lowercase variant is canonical; all + * PascalCase or mixed-case duplicates are dropped. + * + * LOSSY: when a collision is resolved the non-lowercase key is silently dropped, so + * callers only see the lowercase variant in the normalised output. In particular, the + * kept variant may be the nullable one — e.g. the `POST /object/copy` response has both + * `Id` (non-nullable) and `id` (nullable); after dedup only `id` (nullable) survives. + * Downstream generated types therefore reflect the nullable shape even where the + * non-nullable sibling would have been more accurate. + */ +export function dedupCaseInsensitiveProperties(spec: OpenApiDoc): OpenApiDoc { + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if (node.properties && typeof node.properties === "object" && !Array.isArray(node.properties)) { + const props: Record = node.properties; + const seen = new Map(); // lowercase -> first key seen + for (const key of Object.keys(props)) { + const lk = key.toLowerCase(); + if (seen.has(lk)) { + // Keep the lowercase variant; drop the non-lowercase one + const existing = seen.get(lk)!; + if (existing === existing.toLowerCase()) { + // existing is already lowercase; drop current key + delete props[key]; + } else { + // existing is not lowercase; replace with current (closer to lowercase) + delete props[existing]; + seen.set(lk, key); + } + } else { + seen.set(lk, key); + } + } + } + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** + * Converts OAS 3.1-style nullable arrays (`type: ["null", "T"]`) to OAS 3.0 form + * (`type: "T", nullable: true`). Also flattens single-element type arrays. + * Operates recursively on the whole document. + */ +export function fixArrayTypes(spec: OpenApiDoc): OpenApiDoc { + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if (Array.isArray(node.type)) { + const types: string[] = node.type; + const nonNull = types.filter((t) => t !== "null"); + if (types.includes("null")) node.nullable = true; + node.type = nonNull.length === 1 ? nonNull[0] : nonNull.length === 0 ? undefined : nonNull; + if (node.type === undefined) delete node.type; + } + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** + * Removes `$comment` keys from every schema object in the document. + * `$comment` is a JSON Schema / OAS 3.1 annotation; OAS 3.0 does not allow it. + */ +export function stripDollarComments(spec: OpenApiDoc): OpenApiDoc { + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if ("$comment" in node) delete node.$comment; + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** + * Removes `examples` (plural array form) from schema objects. + * OAS 3.0 uses the singular `example` keyword; the plural `examples` is OAS 3.1. + * We only strip it when it sits on a schema node (has `type`, `$ref`, `properties`, + * `allOf`, `oneOf`, or `anyOf`) to avoid touching parameter-level `examples` maps. + */ +export function stripSchemaExamples(spec: OpenApiDoc): OpenApiDoc { + const SCHEMA_SIGNALS = new Set(["type", "$ref", "properties", "allOf", "oneOf", "anyOf", "items", "nullable", "format"]); + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + const isSchemaNode = Object.keys(node).some((k) => SCHEMA_SIGNALS.has(k)); + if (isSchemaNode && Array.isArray(node.examples)) delete node.examples; + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** + * PILOT STOPGAP — replaces any external (`http(s)://`) `$ref` with a permissive open + * object schema `{ type: "object" }`. This discards the referenced schema's real shape + * entirely; the original URL is preserved in `x-inlined-from` for traceability only. + * + * As of the initial Storage codegen pilot the only affected site is the QueryVectors + * request body, whose `$ref` points at an external Hnswlib spec. That one case does + * not yet block generation, but the substitution is semantically wrong. + * + * Follow-up actions before this leaves pilot status: + * 1. Narrow the matcher or vendor the real external schema so the correct shape is + * emitted for known refs (analogous to `schemaRenames` for local refs). + * 2. Consider failing loudly — similar to how `findUnmatchedOverrides` surfaces + * unrecognised override keys — for any external ref that is not explicitly + * allow-listed, so new upstream refs do not silently degrade codegen quality. + * + * The openapi-generator validator rejects any ref it cannot resolve at generation time, + * which is why we must substitute something; the open-object fallback is the safest + * no-op that keeps generation green while the proper fix is tracked. + */ +export function inlineExternalRefs(spec: OpenApiDoc): OpenApiDoc { + const walk = (node: any): void => { + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === "object") { + if (typeof node.$ref === "string" && /^https?:\/\//.test(node.$ref)) { + const originalRef = node.$ref; + delete node.$ref; + // Make it a permissive object schema so the generator still emits something + node.type = "object"; + node["x-inlined-from"] = originalRef; + } + for (const v of Object.values(node)) walk(v); + } + }; + walk(spec); + return spec; +} + +/** Full normalization: wildcard params, then schema renames, then operationId injection. */ +export function normalizeSpec(spec: OpenApiDoc, options: NormalizeOptions = {}): OpenApiDoc { + renameWildcardParams(spec, options.wildcardParamName ?? "objectPath"); + if (options.schemaRenames) renameSchemas(spec, options.schemaRenames); + injectOperationIds(spec, options.operationIdOverrides ?? {}); + if (options.requestBodyInjections) injectRequestBodies(spec, options.requestBodyInjections); + fixArrayTypes(spec); + stripDollarComments(spec); + stripSchemaExamples(spec); + inlineExternalRefs(spec); + dedupCaseInsensitiveProperties(spec); + return spec; +} diff --git a/scripts/capability-matrix/src/types.ts b/scripts/capability-matrix/src/types.ts index d0526ef..7c5b7e5 100644 --- a/scripts/capability-matrix/src/types.ts +++ b/scripts/capability-matrix/src/types.ts @@ -22,11 +22,17 @@ export interface Group { title: string; } +export interface Binding { + spec: string; + operationId: string; +} + export interface Feature { id: string; name: string; description: string; group?: string; + binding?: Binding; } export interface AreaFile { diff --git a/scripts/capability-matrix/test/binding-schema.test.ts b/scripts/capability-matrix/test/binding-schema.test.ts new file mode 100644 index 0000000..2864601 --- /dev/null +++ b/scripts/capability-matrix/test/binding-schema.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { checkSchema } from "../src/schema"; +import type { LoadedArea } from "../src/types"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "capability-matrix.schema.json"), + "utf8", + ), +); + +function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; +} + +describe("feature binding schema", () => { + it("accepts a feature with a valid binding", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }, + ]); + expect(checkSchema([a], schema)).toEqual([]); + }); + + it("rejects a binding missing operationId", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage" } }, + ]); + expect(checkSchema([a], schema).length).toBeGreaterThan(0); + }); + + it("rejects a binding with an unknown property", () => { + const a = area([ + { id: "storage.objects.upload", name: "Upload", description: "d", binding: { spec: "storage", operationId: "uploadObject", extra: true } }, + ]); + expect(checkSchema([a], schema).length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/capability-matrix/test/bindings.test.ts b/scripts/capability-matrix/test/bindings.test.ts new file mode 100644 index 0000000..38d7984 --- /dev/null +++ b/scripts/capability-matrix/test/bindings.test.ts @@ -0,0 +1,87 @@ +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { checkBindings, checkBindingOperations } from "../src/bindings"; +import type { LoadedArea } from "../src/types"; +import type { CodegenConfig } from "../src/codegen"; + +const config: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "x", version: "v1" } }, + languages: { swift: { generator: "swift5", templates: "templates/swift" } }, +}; + +function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; +} + +describe("checkBindings", () => { + it("passes when a binding references a known spec", () => { + const a = area([ + { id: "storage.objects.upload", name: "U", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }, + ]); + expect(checkBindings([a], config)).toEqual([]); + }); + + it("ignores features without a binding", () => { + const a = area([{ id: "storage.objects.upload", name: "U", description: "d" }]); + expect(checkBindings([a], config)).toEqual([]); + }); + + it("errors when a binding references an unknown spec", () => { + const a = area([ + { id: "storage.objects.upload", name: "U", description: "d", binding: { spec: "ghost", operationId: "x" } }, + ]); + const findings = checkBindings([a], config); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('unknown spec "ghost"'); + }); +}); + +describe("checkBindingOperations", () => { + function specDir(operationIds: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "spec-")); + const paths: any = {}; + operationIds.forEach((id, i) => { paths[`/op${i}`] = { get: { operationId: id } }; }); + writeFileSync(join(dir, "storage.normalized.json"), JSON.stringify({ openapi: "3.0.3", paths })); + return dir; + } + const cfg: any = { + engine: { tool: "openapi-generator", version: "7.23.0" }, + specs: { storage: { source: "storage.normalized.json", version: "v1" } }, + languages: { swift: { generator: "swift6" } }, + }; + function area(features: unknown[]): LoadedArea { + return { file: "/x/storage.yaml", area: { area: "storage", title: "T", description: "d", features: features as never } }; + } + + it("passes when a binding's operationId exists in the spec", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.upload", name: "U", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }]); + expect(checkBindingOperations([a], cfg, base)).toEqual([]); + }); + + it("errors when the operationId is not in the spec", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.ghost", name: "G", description: "d", binding: { spec: "storage", operationId: "ghostOp" } }]); + const findings = checkBindingOperations([a], cfg, base); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("ghostOp"); + }); + + it("ignores features without a binding", () => { + const base = specDir(["uploadObject"]); + const a = area([{ id: "storage.x.none", name: "N", description: "d" }]); + expect(checkBindingOperations([a], cfg, base)).toEqual([]); + }); + + it("reports a single read error when the spec file is missing (no duplicate not-present error)", () => { + const dir = mkdtempSync(join(tmpdir(), "spec-")); // empty dir — no spec file written + const badCfg: any = { ...cfg, specs: { storage: { source: "does-not-exist.json", version: "v1" } } }; + const a = area([{ id: "storage.x.u", name: "U", description: "d", binding: { spec: "storage", operationId: "uploadObject" } }]); + const findings = checkBindingOperations([a], badCfg, dir); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("cannot read spec"); + }); +}); diff --git a/scripts/capability-matrix/test/codegen.test.ts b/scripts/capability-matrix/test/codegen.test.ts new file mode 100644 index 0000000..aa5bfe3 --- /dev/null +++ b/scripts/capability-matrix/test/codegen.test.ts @@ -0,0 +1,67 @@ +import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { loadCodegenConfig, checkCodegenConfig } from "../src/codegen"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "codegen.schema.json"), + "utf8", + ), +); + +const valid = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "https://example.com/storage.yaml", version: "v1.2.3" } }, + languages: { swift: { generator: "swift5", templates: "templates/swift" } }, +}; + +describe("checkCodegenConfig", () => { + it("accepts a valid config", () => { + expect(checkCodegenConfig(valid, schema)).toEqual([]); + }); + + it("rejects a config missing engine.version", () => { + const bad = { ...valid, engine: { tool: "openapi-generator" } }; + expect(checkCodegenConfig(bad, schema).length).toBeGreaterThan(0); + }); + + it("rejects a language missing its generator", () => { + const bad = { ...valid, languages: { swift: { templates: "templates/swift" } } }; + expect(checkCodegenConfig(bad, schema).length).toBeGreaterThan(0); + }); + + it("accepts a language without templates (stock generator)", () => { + const cfg = { ...valid, languages: { swift: { generator: "swift6" } } }; + expect(checkCodegenConfig(cfg, schema)).toEqual([]); + }); + + it("accepts an optional targets array", () => { + const cfg = { ...valid, targets: [{ spec: "storage", language: "swift", output: "codegen/generated/swift-storage" }] }; + expect(checkCodegenConfig(cfg, schema)).toEqual([]); + }); + + it("rejects a target missing output", () => { + const cfg = { ...valid, targets: [{ spec: "storage", language: "swift" }] }; + expect(checkCodegenConfig(cfg, schema).length).toBeGreaterThan(0); + }); + + it("rejects an empty targets array", () => { + const cfg = { ...valid, targets: [] }; + expect(checkCodegenConfig(cfg, schema).length).toBeGreaterThan(0); + }); +}); + +describe("loadCodegenConfig", () => { + it("parses a YAML config file", () => { + const dir = mkdtempSync(join(tmpdir(), "codegen-")); + const file = join(dir, "codegen.yaml"); + writeFileSync(file, "engine:\n tool: openapi-generator\n version: 7.10.0\nspecs:\n storage:\n source: x\n version: v1\nlanguages:\n swift:\n generator: swift5\n templates: templates/swift\n"); + const { config, findings } = loadCodegenConfig(file); + expect(findings).toEqual([]); + expect(config?.engine.version).toBe("7.10.0"); + expect(config?.specs.storage.source).toBe("x"); + }); +}); diff --git a/scripts/capability-matrix/test/conformance.test.ts b/scripts/capability-matrix/test/conformance.test.ts new file mode 100644 index 0000000..4d6b6eb --- /dev/null +++ b/scripts/capability-matrix/test/conformance.test.ts @@ -0,0 +1,60 @@ +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { checkConformance } from "../src/conformance"; + +const schema = JSON.parse( + readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "schema", "conformance.schema.json"), + "utf8", + ), +); + +function makeDir(files: Record): string { + const tmp = mkdtempSync(join(tmpdir(), "conf-")); + for (const [rel, content] of Object.entries(files)) { + const parts = rel.split("/"); + if (parts.length > 1) mkdirSync(join(tmp, ...parts.slice(0, -1)), { recursive: true }); + writeFileSync(join(tmp, rel), content); + } + return tmp; +} + +const validVector = "feature: storage.objects.upload\ncases:\n - name: uploads a small file\n input: { path: a.txt, body: hi }\n expected: { status: 200 }\n"; + +describe("checkConformance", () => { + it("passes when a vector is well-formed and references a known feature", () => { + const dir = makeDir({ "storage/upload.yaml": validVector }); + expect(checkConformance(dir, new Set(["storage.objects.upload"]), schema)).toEqual([]); + }); + + it("errors when a vector references an unknown feature", () => { + const dir = makeDir({ "storage/upload.yaml": validVector }); + const findings = checkConformance(dir, new Set(["storage.objects.list"]), schema); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("storage.objects.upload"); + }); + + it("errors when a vector is malformed (missing cases)", () => { + const dir = makeDir({ "storage/bad.yaml": "feature: storage.objects.upload\n" }); + expect(checkConformance(dir, new Set(["storage.objects.upload"]), schema).length).toBeGreaterThan(0); + }); + + it("returns empty when the conformance directory does not exist", () => { + expect(checkConformance("/nonexistent/conf-xyzzy", new Set(), schema)).toEqual([]); + }); + + it("ignores non-.yaml files", () => { + const dir = makeDir({ "storage/upload.yaml": validVector, "storage/notes.txt": "ignored" }); + expect(checkConformance(dir, new Set(["storage.objects.upload"]), schema)).toEqual([]); + }); + + it("recurses into nested subdirectories", () => { + const dir = makeDir({ "storage/objects/upload.yaml": validVector }); + const findings = checkConformance(dir, new Set(["storage.objects.list"]), schema); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain("storage.objects.upload"); + }); +}); diff --git a/scripts/capability-matrix/test/generate.test.ts b/scripts/capability-matrix/test/generate.test.ts new file mode 100644 index 0000000..e076cde --- /dev/null +++ b/scripts/capability-matrix/test/generate.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { buildGenerateArgs } from "../src/generate"; +import type { CodegenConfig } from "../src/codegen"; + +const config: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.10.0" }, + specs: { storage: { source: "https://example.com/storage.yaml", version: "v1" } }, + languages: { + swift: { generator: "swift5", templates: "templates/swift", generatorProperties: { library: "urlsession", useJsonEncodable: "false" } }, + }, +}; + +describe("buildGenerateArgs", () => { + it("builds the generate command for a target", () => { + const args = buildGenerateArgs(config, { spec: "storage", language: "swift", outDir: "generated/storage" }); + expect(args).toEqual([ + "generate", + "--input-spec", "https://example.com/storage.yaml", + "--generator-name", "swift5", + "--output", "generated/storage", + "--template-dir", "templates/swift", + "--additional-properties=library=urlsession,useJsonEncodable=false", + ]); + }); + + it("omits --additional-properties when there are none", () => { + const bare: CodegenConfig = { ...config, languages: { swift: { generator: "swift5", templates: "templates/swift" } } }; + const args = buildGenerateArgs(bare, { spec: "storage", language: "swift", outDir: "out" }); + expect(args).not.toContain("--additional-properties"); + expect(args.some((a) => a.startsWith("--additional-properties"))).toBe(false); + }); + + it("throws on an unknown spec", () => { + expect(() => buildGenerateArgs(config, { spec: "ghost", language: "swift", outDir: "out" })).toThrow(/unknown spec/); + }); + + it("throws on an unknown language", () => { + expect(() => buildGenerateArgs(config, { spec: "storage", language: "cobol", outDir: "out" })).toThrow(/unknown language/); + }); + + it("omits --template-dir when the language has no templates", () => { + const stock: CodegenConfig = { + engine: { tool: "openapi-generator", version: "7.23.0" }, + specs: { storage: { source: "codegen/specs/storage.normalized.json", version: "v1" } }, + languages: { swift: { generator: "swift6" } }, + }; + const args = buildGenerateArgs(stock, { spec: "storage", language: "swift", outDir: "out" }); + expect(args).toEqual([ + "generate", + "--input-spec", "codegen/specs/storage.normalized.json", + "--generator-name", "swift6", + "--output", "out", + ]); + }); +}); diff --git a/scripts/capability-matrix/test/normalize.test.ts b/scripts/capability-matrix/test/normalize.test.ts new file mode 100644 index 0000000..2187e31 --- /dev/null +++ b/scripts/capability-matrix/test/normalize.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from "vitest"; +import { renameWildcardParams, renameSchemas, deriveOperationId, injectOperationIds, normalizeSpec, findUnmatchedOverrides, injectRequestBodies, fixArrayTypes, stripDollarComments, stripSchemaExamples, inlineExternalRefs, dedupCaseInsensitiveProperties } from "../src/normalize"; + +describe("renameWildcardParams", () => { + it("renames {*} path keys and their `*` path params", () => { + const spec: any = { + paths: { + "/cdn/{bucketName}/{*}": { + delete: { parameters: [ + { in: "path", name: "bucketName", required: true, schema: { type: "string" } }, + { in: "path", name: "*", required: true, schema: { type: "string" } }, + ] }, + }, + }, + }; + renameWildcardParams(spec, "objectPath"); + expect(spec.paths["/cdn/{bucketName}/{*}"]).toBeUndefined(); + const op = spec.paths["/cdn/{bucketName}/{objectPath}"].delete; + expect(op.parameters.find((p: any) => p.in === "path" && p.name === "objectPath")).toBeTruthy(); + expect(op.parameters.find((p: any) => p.name === "*")).toBeUndefined(); + }); +}); + +describe("renameSchemas", () => { + it("leaves a schema intact when old and new names are equal", () => { + const spec: any = { components: { schemas: { Foo: { type: "object" } } } }; + renameSchemas(spec, { Foo: "Foo" }); + expect(spec.components.schemas.Foo).toBeTruthy(); + }); + + it("renames component schema keys and rewrites $refs", () => { + const spec: any = { + components: { schemas: { "def-1": { type: "object" } } }, + paths: { "/x": { get: { responses: { "4XX": { content: { "application/json": { schema: { $ref: "#/components/schemas/def-1" } } } } } } } }, + }; + renameSchemas(spec, { "def-1": "ErrorBody" }); + expect(spec.components.schemas["def-1"]).toBeUndefined(); + expect(spec.components.schemas["ErrorBody"]).toBeTruthy(); + expect(spec.paths["/x"].get.responses["4XX"].content["application/json"].schema.$ref).toBe("#/components/schemas/ErrorBody"); + }); +}); + +describe("deriveOperationId", () => { + it("derives a deterministic camelCase id", () => { + expect(deriveOperationId("head", "/bucket")).toBe("headBucket"); + expect(deriveOperationId("get", "/bucket/{bucketId}")).toBe("getBucketByBucketId"); + }); +}); + +describe("injectOperationIds", () => { + it("injects derived ids, honors overrides, and keeps them unique", () => { + const spec: any = { + paths: { + "/bucket": { get: {}, post: {} }, + "/object/{bucketName}/{objectPath}": { post: {} }, + }, + }; + injectOperationIds(spec, { "POST /object/{bucketName}/{objectPath}": "uploadObject" }); + expect(spec.paths["/bucket"].get.operationId).toBe("getBucket"); + expect(spec.paths["/bucket"].post.operationId).toBe("postBucket"); + expect(spec.paths["/object/{bucketName}/{objectPath}"].post.operationId).toBe("uploadObject"); + }); + + it("does not overwrite an existing operationId", () => { + const spec: any = { paths: { "/x": { get: { operationId: "keepMe" } } } }; + injectOperationIds(spec, {}); + expect(spec.paths["/x"].get.operationId).toBe("keepMe"); + }); +}); + +describe("findUnmatchedOverrides", () => { + it("flags override keys that match no operation", () => { + const spec: any = { paths: { "/bucket/": { get: {}, post: {} } } }; + const unmatched = findUnmatchedOverrides(spec, { "GET /bucket/": "listBuckets", "GET /bucket": "nope", "POST /nope": "x" }); + expect(unmatched.sort()).toEqual(["GET /bucket", "POST /nope"]); + }); + + it("findUnmatchedOverrides works with object-valued maps (request body injections)", () => { + const spec: any = { paths: { "/object/{bucketName}/{objectPath}": { post: {} } } }; + expect(findUnmatchedOverrides(spec, { "POST /object/{bucketName}/{objectPath}": { any: "object" }, "POST /nope": {} })).toEqual(["POST /nope"]); + }); +}); + +describe("injectRequestBodies", () => { + const octet = { required: true, content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } } }; + + it("injects a requestBody when the operation has none", () => { + const spec: any = { paths: { "/object/{bucketName}/{objectPath}": { post: { operationId: "uploadObject" } } } }; + injectRequestBodies(spec, { "POST /object/{bucketName}/{objectPath}": octet }); + expect(spec.paths["/object/{bucketName}/{objectPath}"].post.requestBody).toEqual(octet); + }); + + it("does not overwrite an existing requestBody", () => { + const existing = { content: { "application/json": {} } }; + const spec: any = { paths: { "/x": { post: { requestBody: existing } } } }; + injectRequestBodies(spec, { "POST /x": octet }); + expect(spec.paths["/x"].post.requestBody).toEqual(existing); + }); + + it("ignores keys whose operation does not exist", () => { + const spec: any = { paths: { "/x": { post: {} } } }; + injectRequestBodies(spec, { "POST /nope": octet }); + expect(spec.paths["/x"].post.requestBody).toBeUndefined(); + }); +}); + +describe("fixArrayTypes", () => { + it("converts ['null', 'string'] to { type: 'string', nullable: true }", () => { + const spec: any = { components: { schemas: { Foo: { type: ["null", "string"] } } } }; + fixArrayTypes(spec); + expect(spec.components.schemas.Foo.type).toBe("string"); + expect(spec.components.schemas.Foo.nullable).toBe(true); + }); + + it("flattens single-element type array without adding nullable", () => { + const spec: any = { components: { schemas: { Bar: { type: ["integer"] } } } }; + fixArrayTypes(spec); + expect(spec.components.schemas.Bar.type).toBe("integer"); + expect(spec.components.schemas.Bar.nullable).toBeUndefined(); + }); +}); + +describe("stripDollarComments", () => { + it("removes $comment keys from schema objects", () => { + const spec: any = { components: { schemas: { Foo: { type: "object", $comment: "internal note" } } } }; + stripDollarComments(spec); + expect(spec.components.schemas.Foo.$comment).toBeUndefined(); + expect(spec.components.schemas.Foo.type).toBe("object"); + }); +}); + +describe("stripSchemaExamples", () => { + it("removes plural examples array from a schema node", () => { + const spec: any = { components: { schemas: { Foo: { type: "integer", examples: [1, 2] } } } }; + stripSchemaExamples(spec); + expect(spec.components.schemas.Foo.examples).toBeUndefined(); + }); + + it("keeps singular example value on schema nodes", () => { + const spec: any = { components: { schemas: { Foo: { type: "string", example: "hello" } } } }; + stripSchemaExamples(spec); + expect(spec.components.schemas.Foo.example).toBe("hello"); + }); +}); + +describe("inlineExternalRefs", () => { + it("replaces HTTP $ref with inline empty object schema", () => { + const spec: any = { paths: { "/x": { post: { requestBody: { content: { "application/json": { schema: { $ref: "https://schemas.example.com/body.json" } } } } } } } }; + inlineExternalRefs(spec); + const schema = spec.paths["/x"].post.requestBody.content["application/json"].schema; + expect(schema.$ref).toBeUndefined(); + expect(schema.type).toBe("object"); + }); +}); + +describe("dedupCaseInsensitiveProperties", () => { + it("drops the PascalCase key when a lowercase duplicate exists", () => { + const spec: any = { + paths: { "/x": { post: { responses: { 200: { content: { "application/json": { schema: { + type: "object", + properties: { Id: { type: "string" }, id: { type: "string", nullable: true }, name: { type: "string" } }, + } } } } } } } }, + }; + dedupCaseInsensitiveProperties(spec); + const props = spec.paths["/x"].post.responses["200"].content["application/json"].schema.properties; + expect(props.Id).toBeUndefined(); + expect(props.id).toBeTruthy(); + expect(props.name).toBeTruthy(); + }); +}); + +describe("normalizeSpec", () => { + it("applies wildcard rename, schema rename, then operationId injection (override keyed on normalized path)", () => { + const spec: any = { + components: { schemas: { "def-1": { type: "object" } } }, + paths: { + "/object/{bucketName}/{*}": { + post: { parameters: [ + { in: "path", name: "bucketName", required: true, schema: { type: "string" } }, + { in: "path", name: "*", required: true, schema: { type: "string" } }, + ], responses: { "4XX": { content: { "application/json": { schema: { $ref: "#/components/schemas/def-1" } } } } } }, + }, + }, + }; + normalizeSpec(spec, { + schemaRenames: { "def-1": "ErrorBody" }, + operationIdOverrides: { "POST /object/{bucketName}/{objectPath}": "uploadObject" }, + }); + const op = spec.paths["/object/{bucketName}/{objectPath}"].post; + expect(op.operationId).toBe("uploadObject"); + expect(op.parameters.some((p: any) => p.name === "objectPath")).toBe(true); + expect(spec.components.schemas["ErrorBody"]).toBeTruthy(); + }); +}); diff --git a/scripts/capability-matrix/test/run-codegen.test.ts b/scripts/capability-matrix/test/run-codegen.test.ts new file mode 100644 index 0000000..393a1ee --- /dev/null +++ b/scripts/capability-matrix/test/run-codegen.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, it, expect, afterEach } from "vitest"; +import { run } from "../src/cli"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const schema = JSON.parse(readFileSync(join(root, "schema", "capability-matrix.schema.json"), "utf8")); +const codegenSchema = JSON.parse(readFileSync(join(root, "schema", "codegen.schema.json"), "utf8")); + +describe("run() with codegen checks", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("flags a feature bound to a spec absent from codegen.yaml", async () => { + const capDir = mkdtempSync(join(tmpdir(), "cap-")); + tempDirs.push(capDir); + writeFileSync( + join(capDir, "storage.yaml"), + "area: storage\ntitle: Storage\ndescription: d\nfeatures:\n - id: storage.objects.upload\n name: Upload\n description: d\n binding:\n spec: ghost\n operationId: uploadObject\n", + ); + const cfgDir = mkdtempSync(join(tmpdir(), "cfg-")); + tempDirs.push(cfgDir); + const cfgPath = join(cfgDir, "codegen.yaml"); + writeFileSync( + cfgPath, + "engine:\n tool: openapi-generator\n version: 7.10.0\nspecs:\n storage:\n source: x\n version: v1\nlanguages:\n swift:\n generator: swift5\n templates: templates/swift\n", + ); + + const result = await run({ mode: "validate", capabilitiesDir: capDir, schema, codegenConfigPath: cfgPath, codegenSchema }); + expect(result.findings.some((f) => f.message.includes('unknown spec "ghost"'))).toBe(true); + expect(result.errorCount).toBeGreaterThan(0); + }); + + it("produces no findings when a feature binding references a spec declared in codegen.yaml", async () => { + const capDir = mkdtempSync(join(tmpdir(), "cap-")); + tempDirs.push(capDir); + writeFileSync( + join(capDir, "storage.yaml"), + "area: storage\ntitle: Storage\ndescription: d\nfeatures:\n - id: storage.objects.upload\n name: Upload\n description: d\n binding:\n spec: storage\n operationId: uploadObject\n", + ); + const cfgDir = mkdtempSync(join(tmpdir(), "cfg-")); + tempDirs.push(cfgDir); + const cfgPath = join(cfgDir, "codegen.yaml"); + // Write a minimal spec file so checkBindingOperations can verify the operationId exists. + writeFileSync( + join(cfgDir, "storage.normalized.json"), + JSON.stringify({ openapi: "3.0.3", paths: { "/object/{bucketName}/{objectPath}": { post: { operationId: "uploadObject" } } } }), + ); + writeFileSync( + cfgPath, + "engine:\n tool: openapi-generator\n version: 7.10.0\nspecs:\n storage:\n source: storage.normalized.json\n version: v1\nlanguages:\n swift:\n generator: swift5\n templates: templates/swift\n", + ); + + const result = await run({ mode: "validate", capabilitiesDir: capDir, schema, codegenConfigPath: cfgPath, codegenSchema }); + expect(result.errorCount).toBe(0); + expect(result.findings.every((f) => !f.message.includes("unknown spec"))).toBe(true); + }); +});