diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c3fc5c..2fb97f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: run: rm -r Tests/IntegrationTests/* - name: "Build Swift Package" run: swift build - + # android: # name: Android # runs-on: ubuntu-latest @@ -139,18 +139,18 @@ jobs: # # tests are not yet passing on Android # run-tests: false - library-evolution: - name: Library (evolution) - runs-on: macos-15 - strategy: - matrix: - xcode: ["16.3"] - steps: - - uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Build for library evolution - run: make build-for-library-evolution + # library-evolution: + # name: Library (evolution) + # runs-on: macos-15 + # strategy: + # matrix: + # xcode: ["16.3"] + # steps: + # - uses: actions/checkout@v4 + # - name: Select Xcode ${{ matrix.xcode }} + # run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + # - name: Build for library evolution + # run: make build-for-library-evolution examples: name: Examples diff --git a/Package.resolved b/Package.resolved index 3e33ade0..c2da59fe 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8f9a7a274a65e1e858bc4af7d28200df656048be2796fc6bcc0b5712f7429bde", + "originHash" : "3d786f6f5c84b30f9fdd7d72ec12b837890313fec033addfe2ed8fbcc54600f3", "pins" : [ { "identity" : "mocker", @@ -28,6 +28,15 @@ "version" : "1.0.6" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -64,6 +73,33 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", + "version" : "1.1.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 42cadc4d..4bb0fbf4 100644 --- a/Package.swift +++ b/Package.swift @@ -24,8 +24,12 @@ let package = Package( targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"]), ], dependencies: [ + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), @@ -37,9 +41,14 @@ let package = Package( .target( name: "Helpers", dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "DequeModule", package: "swift-collections"), .product(name: "HTTPTypes", package: "swift-http-types"), - .product(name: "Clocks", package: "swift-clocks"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + .product(name: "Logging", package: "swift-log"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] ), diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 214c208c..d6266e68 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,6 +1,9 @@ import ConcurrencyExtras import Foundation import HTTPTypes +import HTTPTypesFoundation +import Logging +import OpenAPIURLSession #if canImport(FoundationNetworking) import FoundationNetworking @@ -8,6 +11,7 @@ import HTTPTypes let version = Helpers.version + /// An actor representing a client for invoking functions. public final class FunctionsClient: Sendable { /// Fetch handler used to make requests. @@ -31,9 +35,8 @@ public final class FunctionsClient: Sendable { var headers = HTTPFields() } - private let http: any HTTPClientType + private let client: Client private let mutableState = LockIsolated(MutableState()) - private let sessionConfiguration: URLSessionConfiguration var headers: HTTPFields { mutableState.headers @@ -47,59 +50,59 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) + @available( + *, + deprecated, + message: "Fetch handler is deprecated, use init with `transport` instead." + ) @_disfavoredOverload public convenience init( url: URL, headers: [String: String] = [:], region: String? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + fetch: @escaping FetchHandler ) { self.init( url: url, headers: headers, region: region, logger: logger, - fetch: fetch, - sessionConfiguration: .default + client: Client(serverURL: url, transport: FetchTransportAdapter(fetch: fetch)) ) } - convenience init( + @_disfavoredOverload + public convenience init( url: URL, headers: [String: String] = [:], region: String? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, - sessionConfiguration: URLSessionConfiguration + transport: (any ClientTransport)? = nil ) { - var interceptors: [any HTTPClientInterceptor] = [] - if let logger { - interceptors.append(LoggerInterceptor(logger: logger)) - } - - let http = HTTPClient(fetch: fetch, interceptors: interceptors) - self.init( url: url, headers: headers, region: region, - http: http, - sessionConfiguration: sessionConfiguration + logger: logger, + client: Client( + serverURL: url, + transport: transport ?? URLSessionTransport(), + middlewares: [LoggingMiddleware(logger: .functions)] + ) ) } init( url: URL, - headers: [String: String], - region: String?, - http: any HTTPClientType, - sessionConfiguration: URLSessionConfiguration = .default + headers: [String: String] = [:], + region: String? = nil, + logger: (any SupabaseLogger)? = nil, + client: Client ) { self.url = url self.region = region - self.http = http - self.sessionConfiguration = sessionConfiguration + self.client = client mutableState.withValue { $0.headers = HTTPFields(headers) @@ -117,14 +120,42 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) + + @available( + *, + deprecated, + message: "Fetch handler is deprecated, use init with `transport` instead." + ) + public convenience init( + url: URL, + headers: [String: String] = [:], + region: FunctionRegion? = nil, + logger: (any SupabaseLogger)? = nil, + fetch: @escaping FetchHandler + ) { + self.init( + url: url, + headers: headers, + region: region?.rawValue, + logger: logger, + fetch: fetch + ) + } + public convenience init( url: URL, headers: [String: String] = [:], region: FunctionRegion? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + transport: (any ClientTransport)? = nil ) { - self.init(url: url, headers: headers, region: region?.rawValue, logger: logger, fetch: fetch) + self.init( + url: url, + headers: headers, + region: region?.rawValue, + logger: logger, + transport: transport + ) } /// Updates the authorization header. @@ -140,6 +171,40 @@ public final class FunctionsClient: Sendable { } } + /// Inokes a functions returns the raw response and body. + /// - Parameters: + /// - functionName: The name of the function to invoke. + /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - Returns: The raw response and body. + @discardableResult + public func invoke( + _ functionName: String, + options: FunctionInvokeOptions = .init() + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody) { + try await self.invoke(functionName, options: options) { ($0, $1) } + } + + /// Invokes a function and decodes the response. + /// + /// - Parameters: + /// - functionName: The name of the function to invoke. + /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) + /// - decode: A closure to decode the response data and `HTTPResponse` into a `Response` + /// object. + /// - Returns: The decoded `Response` object. + public func invoke( + _ functionName: String, + options: FunctionInvokeOptions = .init(), + decode: (HTTPTypes.HTTPResponse, HTTPBody) async throws -> Response + ) async throws -> Response { + let (_, response, body) = try await _invoke( + functionName: functionName, + invokeOptions: options + ) + + return try await decode(response, body) + } + /// Invokes a function and decodes the response. /// /// - Parameters: @@ -148,15 +213,20 @@ public final class FunctionsClient: Sendable { /// - decode: A closure to decode the response data and HTTPURLResponse into a `Response` /// object. /// - Returns: The decoded `Response` object. + @available(*, deprecated, message: "Use `invoke` with HTTPBody instead.") public func invoke( _ functionName: String, options: FunctionInvokeOptions = .init(), decode: (Data, HTTPURLResponse) throws -> Response ) async throws -> Response { - let response = try await rawInvoke( - functionName: functionName, invokeOptions: options + let (request, response, body) = try await _invoke( + functionName: functionName, + invokeOptions: options ) - return try decode(response.data, response.underlyingResponse) + + let data = try await Data(collecting: body, upTo: .max) + + return try decode(data, HTTPURLResponse(httpResponse: response, url: request.url ?? self.url)!) } /// Invokes a function and decodes the response as a specific type. @@ -171,138 +241,59 @@ public final class FunctionsClient: Sendable { options: FunctionInvokeOptions = .init(), decoder: JSONDecoder = JSONDecoder() ) async throws -> T { - try await invoke(functionName, options: options) { data, _ in - try decoder.decode(T.self, from: data) + try await invoke(functionName, options: options) { _, body in + let data = try await Data(collecting: body, upTo: .max) + return try decoder.decode(T.self, from: data) } } - /// Invokes a function without expecting a response. - /// - /// - Parameters: - /// - functionName: The name of the function to invoke. - /// - options: Options for invoking the function. (Default: empty `FunctionInvokeOptions`) - public func invoke( - _ functionName: String, - options: FunctionInvokeOptions = .init() - ) async throws { - try await invoke(functionName, options: options) { _, _ in () } - } - - private func rawInvoke( + private func _invoke( functionName: String, invokeOptions: FunctionInvokeOptions - ) async throws -> Helpers.HTTPResponse { - let request = buildRequest(functionName: functionName, options: invokeOptions) - let response = try await http.send(request) + ) async throws -> (HTTPTypes.HTTPRequest, HTTPTypes.HTTPResponse, HTTPBody) { + let (request, requestBody) = buildRequest(functionName: functionName, options: invokeOptions) + let (response, responseBody) = try await client.send(request, body: requestBody) - guard 200..<300 ~= response.statusCode else { - throw FunctionsError.httpError(code: response.statusCode, data: response.data) + guard response.status.kind == .successful else { + let data = try await Data(collecting: responseBody, upTo: .max) + throw FunctionsError.httpError(code: response.status.code, data: data) } - let isRelayError = response.headers[.xRelayError] == "true" + let isRelayError = response.headerFields[.xRelayError] == "true" if isRelayError { throw FunctionsError.relayError } - return response + return (request, response, responseBody) } - /// Invokes a function with streamed response. - /// - /// Function MUST return a `text/event-stream` content type for this method to work. - /// - /// - Parameters: - /// - functionName: The name of the function to invoke. - /// - invokeOptions: Options for invoking the function. - /// - Returns: A stream of Data. - /// - /// - Warning: Experimental method. - /// - Note: This method doesn't use the same underlying `URLSession` as the remaining methods in the library. - public func _invokeWithStreamedResponse( - _ functionName: String, - options invokeOptions: FunctionInvokeOptions = .init() - ) -> AsyncThrowingStream { - let (stream, continuation) = AsyncThrowingStream.makeStream() - let delegate = StreamResponseDelegate(continuation: continuation) - - let session = URLSession( - configuration: sessionConfiguration, delegate: delegate, delegateQueue: nil) - - let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest - - let task = session.dataTask(with: urlRequest) - task.resume() + private func buildRequest( + functionName: String, + options: FunctionInvokeOptions + ) -> (HTTPTypes.HTTPRequest, HTTPBody?) { + var region = options.region + var queryItems = options.query + var headers = options.headers - continuation.onTermination = { _ in - task.cancel() + // TODO: Check how to assign FunctionsClient.requestIdleTimeout - // Hold a strong reference to delegate until continuation terminates. - _ = delegate + if region == nil { + region = self.region } - return stream - } + if let region, region != "any" { + headers[.xRegion] = region + queryItems.append(URLQueryItem(name: "forceFunctionRegion", value: region)) + } - private func buildRequest(functionName: String, options: FunctionInvokeOptions) - -> Helpers.HTTPRequest - { - var request = HTTPRequest( - url: url.appendingPathComponent(functionName), + let request = HTTPTypes.HTTPRequest( method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, - query: options.query, - headers: mutableState.headers.merging(with: options.headers), - body: options.body, - timeoutInterval: FunctionsClient.requestIdleTimeout + url: url.appendingPathComponent(functionName).appendingQueryItems(queryItems), + headerFields: mutableState.headers.merging(with: headers) ) - if let region = options.region ?? region { - request.headers[.xRegion] = region - } - - return request - } -} - -final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { - let continuation: AsyncThrowingStream.Continuation - - init(continuation: AsyncThrowingStream.Continuation) { - self.continuation = continuation - } - - func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive data: Data) { - continuation.yield(data) - } - - func urlSession(_: URLSession, task _: URLSessionTask, didCompleteWithError error: (any Error)?) { - continuation.finish(throwing: error) - } - - func urlSession( - _: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void - ) { - defer { - completionHandler(.allow) - } - - guard let httpResponse = response as? HTTPURLResponse else { - continuation.finish(throwing: URLError(.badServerResponse)) - return - } - - guard 200..<300 ~= httpResponse.statusCode else { - let error = FunctionsError.httpError( - code: httpResponse.statusCode, - data: Data() - ) - continuation.finish(throwing: error) - return - } + let body = options.body.map(HTTPBody.init) - let isRelayError = httpResponse.value(forHTTPHeaderField: "x-relay-error") == "true" - if isRelayError { - continuation.finish(throwing: FunctionsError.relayError) - } + return (request, body) } } diff --git a/Sources/Functions/Logger.swift b/Sources/Functions/Logger.swift new file mode 100644 index 00000000..a0638114 --- /dev/null +++ b/Sources/Functions/Logger.swift @@ -0,0 +1,13 @@ +// +// Logger.swift +// Supabase +// +// Created by Guilherme Souza on 05/08/25. +// + +import Logging + +extension Logger { + /// A Logger instance for the Functions module. + static let functions = Logger(label: "Functions") +} diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index e53f06fd..440c9116 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -131,6 +131,7 @@ public enum FunctionRegion: String, Sendable { case usEast1 = "us-east-1" case usWest1 = "us-west-1" case usWest2 = "us-west-2" + case any = "any" } extension FunctionInvokeOptions { diff --git a/Sources/Helpers/HTTP/Client.swift b/Sources/Helpers/HTTP/Client.swift new file mode 100644 index 00000000..01bc676b --- /dev/null +++ b/Sources/Helpers/HTTP/Client.swift @@ -0,0 +1,90 @@ +import HTTPTypes +import OpenAPIRuntime + +#if canImport(Darwin) + import struct Foundation.URL + import struct Foundation.URLComponents +#else + @preconcurrency import struct Foundation.URL + @preconcurrency import struct Foundation.URLComponents +#endif + +/// A client that can send HTTP requests and receive HTTP responses. +package struct Client: Sendable { + + /// The URL of the server, used as the base URL for requests made by the + /// client. + let serverURL: URL + + /// A type capable of sending HTTP requests and receiving HTTP responses. + var transport: any ClientTransport + + /// The middlewares to be invoked before the transport. + var middlewares: [any ClientMiddleware] + + /// Creates a new client. + package init( + serverURL: URL, + transport: any ClientTransport, + middlewares: [any ClientMiddleware] = [] + ) { + self.serverURL = serverURL.baseURL + self.transport = transport + self.middlewares = middlewares + } + + /// Sends the HTTP request and returns the HTTP response. + /// + /// - Parameters: + /// - request: The HTTP request to send. + /// - body: The HTTP request body to send. + /// - Returns: The HTTP response and its body. + /// - Throws: An error if any part of the HTTP operation process fails. + package func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody? = nil + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody) { + let baseURL = serverURL + var next: + @Sendable (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPTypes.HTTPResponse, HTTPBody + ) = { + (_request, _body, _url) in + let (response, body) = try await transport.send( + _request, + body: _body, + baseURL: _url, + operationID: "" + ) + return (response, body ?? HTTPBody()) + } + for middleware in middlewares.reversed() { + let tmp = next + next = { (_request, _body, _url) in + let (response, body) = try await middleware.intercept( + _request, + body: _body, + baseURL: _url, + operationID: "", + next: tmp + ) + return (response, body ?? HTTPBody()) + } + } + return try await next(request, body, baseURL) + } +} + +extension URL { + /// Returns a new URL which contains only `{scheme}://{host}:{port}`. + fileprivate var baseURL: URL { + guard let components = URLComponents(string: self.absoluteString) else { return self } + + var newComponents = URLComponents() + newComponents.scheme = components.scheme + newComponents.host = components.host + newComponents.port = components.port + + return newComponents.url ?? self + } +} diff --git a/Sources/Helpers/HTTP/Exports.swift b/Sources/Helpers/HTTP/Exports.swift new file mode 100644 index 00000000..59f51d5f --- /dev/null +++ b/Sources/Helpers/HTTP/Exports.swift @@ -0,0 +1,7 @@ +@_exported import HTTPTypes + +import protocol OpenAPIRuntime.ClientTransport +import class OpenAPIRuntime.HTTPBody + +public typealias ClientTransport = OpenAPIRuntime.ClientTransport +public typealias HTTPBody = OpenAPIRuntime.HTTPBody diff --git a/Sources/Helpers/HTTP/FetchTransportAdapter.swift b/Sources/Helpers/HTTP/FetchTransportAdapter.swift new file mode 100644 index 00000000..4c602f7a --- /dev/null +++ b/Sources/Helpers/HTTP/FetchTransportAdapter.swift @@ -0,0 +1,43 @@ +import Foundation +import HTTPTypes +import HTTPTypesFoundation +import OpenAPIRuntime + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// A ClientTransport implementation that adapts the old Fetch api. +package struct FetchTransportAdapter: ClientTransport { + let fetch: @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse) + + package init(fetch: @escaping @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse)) { + self.fetch = fetch + } + + package func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + guard var urlRequest = URLRequest(httpRequest: request) else { + throw URLError(.badURL) + } + + if let body { + urlRequest.httpBody = try await Data(collecting: body, upTo: .max) + } + + let (data, response) = try await fetch(urlRequest) + + guard let httpURLResponse = response as? HTTPURLResponse, + let httpResponse = httpURLResponse.httpResponse + else { + throw URLError(.badServerResponse) + } + + let body = HTTPBody(data) + return (httpResponse, body) + } +} \ No newline at end of file diff --git a/Sources/Helpers/HTTP/LoggerInterceptor.swift b/Sources/Helpers/HTTP/LoggerInterceptor.swift index e5881953..d39e2d75 100644 --- a/Sources/Helpers/HTTP/LoggerInterceptor.swift +++ b/Sources/Helpers/HTTP/LoggerInterceptor.swift @@ -7,6 +7,7 @@ import Foundation +@available(*, deprecated, message: "Use `LoggingMiddleware instead.") package struct LoggerInterceptor: HTTPClientInterceptor { let logger: any SupabaseLogger diff --git a/Sources/Helpers/HTTP/LoggingMiddleware.swift b/Sources/Helpers/HTTP/LoggingMiddleware.swift new file mode 100644 index 00000000..744e526b --- /dev/null +++ b/Sources/Helpers/HTTP/LoggingMiddleware.swift @@ -0,0 +1,58 @@ +import Logging +import OpenAPIRuntime +import HTTPTypesFoundation + +#if canImport(Darwin) + import struct Foundation.URL + import struct Foundation.UUID +#else + @preconcurrency import struct Foundation.URL + @preconcurrency import struct Foundation.UUID +#endif + +package struct LoggingMiddleware: ClientMiddleware { + let logger: Logger + + package init(logger: Logger) { + self.logger = logger + } + + package func intercept( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: (HTTPTypes.HTTPRequest, HTTPBody?, URL) async throws -> ( + HTTPTypes.HTTPResponse, HTTPBody? + ) + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + var logger = logger + logger[metadataKey: "request-id"] = .string(UUID().uuidString) + + logger.trace("⬆️ \(request.prettyDescription)") + let (response, body) = try await next(request, body, baseURL) + logger.trace("⬇️ \(response.prettyDescription)") + return (response, body) + } +} + +extension HTTPFields { + fileprivate var prettyDescription: String { + sorted(by: { + $0.name.canonicalName.localizedCompare($1.name.canonicalName) == .orderedAscending + }) + .map { "\($0.name.canonicalName): \($0.value)" }.joined(separator: "; ") + } +} + +extension HTTPTypes.HTTPRequest { + fileprivate var prettyDescription: String { + "\(method.rawValue) \(self.url?.absoluteString ?? "") [\(headerFields.prettyDescription)]" + } +} + +extension HTTPTypes.HTTPResponse { + fileprivate var prettyDescription: String { "\(status.code) [\(headerFields.prettyDescription)]" } +} + +extension HTTPBody { fileprivate var prettyDescription: String { String(describing: self) } } diff --git a/Sources/Supabase/AuthClientTransport.swift b/Sources/Supabase/AuthClientTransport.swift new file mode 100644 index 00000000..69341dd0 --- /dev/null +++ b/Sources/Supabase/AuthClientTransport.swift @@ -0,0 +1,30 @@ +// +// AuthClientTransport.swift +// Supabase +// +// Created by Guilherme Souza on 05/08/25. +// + +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +struct AuthClientTransport: ClientTransport { + let transport: any ClientTransport + let accessToken: @Sendable () async -> String? + + func send( + _ request: HTTPTypes.HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> (HTTPTypes.HTTPResponse, HTTPBody?) { + var request = request + if let token = await accessToken() { + request.headerFields[.authorization] = "Bearer \(token)" + } + return try await transport.send(request, body: body, baseURL: baseURL, operationID: operationID) + } +} diff --git a/Sources/Supabase/SupabaseClient.swift b/Sources/Supabase/SupabaseClient.swift index b419a94e..99699fcc 100644 --- a/Sources/Supabase/SupabaseClient.swift +++ b/Sources/Supabase/SupabaseClient.swift @@ -3,6 +3,8 @@ import Foundation import HTTPTypes import IssueReporting +import struct OpenAPIURLSession.URLSessionTransport + #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -16,6 +18,26 @@ public final class SupabaseClient: Sendable { let databaseURL: URL let functionsURL: URL + /// The base transport used by all modules. + /// + /// Use this instance when no authentication is needed. + private let transport: any ClientTransport + + /// The transport which injects the access token before forwarding request to `transport`. + /// + /// Use this instance when authentication is needed. + private var authTransport: any ClientTransport { + mutableState.withValue { + if $0.authTransport == nil { + $0.authTransport = AuthClientTransport( + transport: transport, + accessToken: { try? await self._getAccessToken() } + ) + } + return $0.authTransport! + } + } + private let _auth: AuthClient /// Supabase Auth allows you to create and manage user sessions for access to data that is secured by access policies. @@ -89,7 +111,7 @@ public final class SupabaseClient: Sendable { headers: headers, region: options.functions.region, logger: options.global.logger, - fetch: fetchWithAuth + transport: authTransport ) } @@ -113,6 +135,7 @@ public final class SupabaseClient: Sendable { var realtime: RealtimeClientV2? var changedAccessToken: String? + var authTransport: AuthClientTransport? } let mutableState = LockIsolated(MutableState()) @@ -149,6 +172,12 @@ public final class SupabaseClient: Sendable { self.supabaseKey = supabaseKey self.options = options + self.transport = URLSessionTransport( + configuration: URLSessionTransport.Configuration( + session: options.global.session + ) + ) + storageURL = supabaseURL.appendingPathComponent("/storage/v1") databaseURL = supabaseURL.appendingPathComponent("/rest/v1") functionsURL = supabaseURL.appendingPathComponent("/functions/v1") diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index d0804396..db2337d2 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "eb8870b1127e99897481c021b781eada7e8131a09465fb0cafa63e5a3fc7dc0d", "pins" : [ { "identity" : "appauth-ios", @@ -171,6 +172,33 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "8f33cc5dfe81169fb167da73584b9c72c3e8bc23", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "6fac6f7c428d5feea2639b5f5c8b06ddfb79434b", + "version" : "1.1.0" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", @@ -208,5 +236,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 2d19c5d2..209700b1 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras import HTTPTypes import InlineSnapshotTesting import Mocker +import OpenAPIURLSession import TestHelpers import XCTest @@ -11,19 +12,19 @@ import XCTest import FoundationNetworking #endif +extension URLSessionConfiguration { + static var mocking: URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + return configuration + } +} + final class FunctionsClientTests: XCTestCase { let url = URL(string: "http://localhost:5432/functions/v1")! let apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" - let sessionConfiguration: URLSessionConfiguration = { - let sessionConfiguration = URLSessionConfiguration.default - sessionConfiguration.protocolClasses = [MockingURLProtocol.self] - return sessionConfiguration - }() - - lazy var session = URLSession(configuration: sessionConfiguration) - var region: String? lazy var sut = FunctionsClient( @@ -32,15 +33,23 @@ final class FunctionsClientTests: XCTestCase { "apikey": apiKey ], region: region, - fetch: { request in - try await self.session.data(for: request) - }, - sessionConfiguration: sessionConfiguration + client: Client( + serverURL: URL(string: "http://localhost:5432")!, + transport: URLSessionTransport( + configuration: URLSessionTransport.Configuration( + session: URLSession(configuration: .mocking) + ) + ) + ) ) override func setUp() { super.setUp() - // isRecording = true + } + + override func tearDown() { + super.tearDown() + Mocker.removeAll() } func testInit() async { @@ -65,7 +74,6 @@ final class FunctionsClientTests: XCTestCase { #""" curl \ --request POST \ - --header "Content-Length: 19" \ --header "Content-Type: application/json" \ --header "X-Client-Info: functions-swift/0.0.0" \ --header "X-Custom-Key: value" \ @@ -164,6 +172,7 @@ final class FunctionsClientTests: XCTestCase { Mock( url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, statusCode: 200, data: [.post: Data()] ) @@ -174,7 +183,7 @@ final class FunctionsClientTests: XCTestCase { --header "X-Client-Info: functions-swift/0.0.0" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ --header "x-region: ca-central-1" \ - "http://localhost:5432/functions/v1/hello-world" + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ca-central-1" """# } .register() @@ -185,6 +194,7 @@ final class FunctionsClientTests: XCTestCase { func testInvokeWithRegion() async throws { Mock( url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, statusCode: 200, data: [.post: Data()] ) @@ -195,7 +205,7 @@ final class FunctionsClientTests: XCTestCase { --header "X-Client-Info: functions-swift/0.0.0" \ --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ --header "x-region: ca-central-1" \ - "http://localhost:5432/functions/v1/hello-world" + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ca-central-1" """# } .register() @@ -316,86 +326,4 @@ final class FunctionsClientTests: XCTestCase { sut.setAuth(token: nil) XCTAssertNil(sut.headers[.authorization]) } - - func testInvokeWithStreamedResponse() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 200, - data: [.post: Data("hello world".utf8)] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - for try await value in stream { - XCTAssertEqual(String(decoding: value, as: UTF8.self), "hello world") - } - } - - func testInvokeWithStreamedResponseHTTPError() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 300, - data: [.post: Data()] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - do { - for try await _ in stream { - XCTFail("should throw error") - } - } catch let FunctionsError.httpError(code, _) { - XCTAssertEqual(code, 300) - } - } - - func testInvokeWithStreamedResponseRelayError() async throws { - Mock( - url: url.appendingPathComponent("stream"), - statusCode: 200, - data: [.post: Data()], - additionalHeaders: [ - "x-relay-error": "true" - ] - ) - .snapshotRequest { - #""" - curl \ - --request POST \ - --header "X-Client-Info: functions-swift/0.0.0" \ - --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ - "http://localhost:5432/functions/v1/stream" - """# - } - .register() - - let stream = sut._invokeWithStreamedResponse("stream") - - do { - for try await _ in stream { - XCTFail("should throw error") - } - } catch FunctionsError.relayError { - } - } } diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt index b7ebf5c7..72ba071c 100644 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt +++ b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt @@ -3,4 +3,4 @@ curl \ --header "apikey: supabase.anon.key" \ --header "x-client-info: functions-swift/x.y.z" \ --header "x-region: ap-northeast-1" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file + "http://localhost:5432/functions/v1/hello-world?forceFunctionRegion=ap-northeast-1" \ No newline at end of file diff --git a/Tests/IntegrationTests/DotEnv.swift b/Tests/IntegrationTests/DotEnv.swift index 678b89b3..3e9f4bd8 100644 --- a/Tests/IntegrationTests/DotEnv.swift +++ b/Tests/IntegrationTests/DotEnv.swift @@ -1,5 +1,5 @@ enum DotEnv { - static let SUPABASE_URL = "http://localhost:54321" + static let SUPABASE_URL = "http://127.0.0.1:54321" static let SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" static let SUPABASE_SERVICE_ROLE_KEY = diff --git a/Tests/IntegrationTests/FunctionsIntegrationTests.swift b/Tests/IntegrationTests/FunctionsIntegrationTests.swift new file mode 100644 index 00000000..802b270e --- /dev/null +++ b/Tests/IntegrationTests/FunctionsIntegrationTests.swift @@ -0,0 +1,165 @@ +// +// FunctionsIntegrationTests.swift +// Supabase +// +// Created by Guilherme Souza on 04/08/25. +// + +import Supabase +import XCTest + +final class FunctionsIntegrationTests: XCTestCase { + let client = SupabaseClient( + supabaseURL: URL(string: DotEnv.SUPABASE_URL) ?? URL(string: "http://127.0.0.1:54321")!, + supabaseKey: DotEnv.SUPABASE_ANON_KEY + ) + + func testInvokeMirror() async throws { + let response: MirrorResponse = try await client.functions.invoke("mirror") + XCTAssertTrue(response.url.contains("/mirror")) + XCTAssertEqual(response.method, "POST") + } + + func testInvokeMirrorWithClientHeader() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ] + ) + let response: MirrorResponse = try await client.invoke("mirror") + XCTAssertEqual(response.headersDictionary["customheader"], "check me") + } + + func testInvokeMirrorWithInvokeHeader() async throws { + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions(headers: ["Custom-Header": "check me"]) + ) + XCTAssertEqual(response.headersDictionary["custom-header"], "check me") + } + + func testInvokeMirrorSetValidRegionOnRequest() async throws { + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions(region: .apNortheast1) + ) + XCTAssertEqual(response.headersDictionary["x-region"], "ap-northeast-1") + XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-northeast-1")) + } + + func testInvokeWithRegionOverridesRegionInTheClinet() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apNortheast1 + ) + let response: MirrorResponse = try await client.invoke( + "mirror", + options: FunctionInvokeOptions(region: .apSoutheast1) + ) + XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1") + XCTAssertTrue(response.url.contains("forceFunctionRegion=ap-southeast-1")) + } + + func testStartClientWithDefaultRegionInvokeRevertsToAny() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apSoutheast1 + ) + let response: MirrorResponse = try await client.invoke( + "mirror", + options: FunctionInvokeOptions(region: .any) + ) + XCTAssertNil(response.headersDictionary["x-region"]) + } + + func testInvokeRegionSetOnlyOnTheConstructor() async throws { + let client = FunctionsClient( + url: URL(string: "\(DotEnv.SUPABASE_URL)/functions/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_ANON_KEY)", + "CustomHeader": "check me", + ], + region: .apSoutheast1 + ) + let response: MirrorResponse = try await client.invoke("mirror") + XCTAssertEqual(response.headersDictionary["x-region"], "ap-southeast-1") + } + + func testInvokeMirrorWithBodyFormData() async throws { + throw XCTSkip("Unsupported body type.") + } + + func testInvokeMirrowWithEncodableBody() async throws { + let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false) + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions( + headers: [ + "response-type": "json" + ], + body: body + ) + ) + let responseBody = try response.body.decode(as: Body.self, decoder: JSONDecoder()) + XCTAssertEqual(responseBody, body) + + XCTAssertEqual(response.headersDictionary["content-type"], "application/json") + XCTAssertEqual(response.headersDictionary["response-type"], "json") + } + + func testInvokeMirrowWithDataBody() async throws { + let body = Body(one: "one", two: "two", three: "three", num: 11, flag: false) + + let response: MirrorResponse = try await client.functions.invoke( + "mirror", + options: FunctionInvokeOptions( + headers: [ + "response-type": "blob" + ], + body: try JSONEncoder().encode(body) + ) + ) + + guard let responseBodyData = response.body.stringValue?.data(using: .utf8), + let responseBody = try? JSONDecoder().decode(Body.self, from: responseBodyData) + else { + XCTFail("Expected to receive body response as JSON string.") + return + } + + XCTAssertEqual(responseBody, body) + + XCTAssertEqual(response.headersDictionary["content-type"], "application/octet-stream") + XCTAssertEqual(response.headersDictionary["response-type"], "blob") + } +} + +struct MirrorResponse: Decodable { + let url: String + let method: String + let headers: AnyJSON + let body: AnyJSON + + var headersDictionary: [String: String] { + Dictionary( + uniqueKeysWithValues: headers.arrayValue?.compactMap { + $0.arrayValue?.compactMap(\.stringValue) ?? [] + }.map { ($0[0], $0[1]) } ?? [] + ) + } +} +struct Body: Codable, Equatable { + let one, two, three: String + let num: Int + let flag: Bool +} diff --git a/Tests/IntegrationTests/supabase/.temp/cli-latest b/Tests/IntegrationTests/supabase/.temp/cli-latest index f47ab084..8e00c6d6 100644 --- a/Tests/IntegrationTests/supabase/.temp/cli-latest +++ b/Tests/IntegrationTests/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.22.12 \ No newline at end of file +v2.33.9 \ No newline at end of file diff --git a/Tests/IntegrationTests/supabase/config.toml b/Tests/IntegrationTests/supabase/config.toml index ceb35515..f579e146 100644 --- a/Tests/IntegrationTests/supabase/config.toml +++ b/Tests/IntegrationTests/supabase/config.toml @@ -306,3 +306,14 @@ s3_region = "env(S3_REGION)" s3_access_key = "env(S3_ACCESS_KEY)" # Configures AWS_SECRET_ACCESS_KEY for S3 bucket s3_secret_key = "env(S3_SECRET_KEY)" + +[functions.mirror] +enabled = true +verify_jwt = true +import_map = "./functions/mirror/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/mirror/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/mirror/*.html" ] diff --git a/Tests/IntegrationTests/supabase/functions/mirror/.npmrc b/Tests/IntegrationTests/supabase/functions/mirror/.npmrc new file mode 100644 index 00000000..48c63886 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Tests/IntegrationTests/supabase/functions/mirror/deno.json b/Tests/IntegrationTests/supabase/functions/mirror/deno.json new file mode 100644 index 00000000..f6ca8454 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Tests/IntegrationTests/supabase/functions/mirror/index.ts b/Tests/IntegrationTests/supabase/functions/mirror/index.ts new file mode 100644 index 00000000..16246c7b --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/mirror/index.ts @@ -0,0 +1,66 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "jsr:@supabase/functions-js/edge-runtime.d.ts" +import { serve } from 'https://deno.land/std/http/server.ts' + +serve(async (request: Request) => { + let body + let contentType = 'application/json' + switch (request.headers.get('response-type')) { + case 'json': { + body = await request.json() + break + } + case 'form': { + const formBody = await request.formData() + body = [] + for (const e of formBody.entries()) { + body.push(e) + } + break + } + case 'blob': { + const data = await request.blob() + body = await data.text() + contentType = 'application/octet-stream' + break + } + case 'arrayBuffer': { + const data = await request.arrayBuffer() + body = new TextDecoder().decode(data || new Uint8Array()) + contentType = 'application/octet-stream' + break + } + default: { + body = await request.text() + contentType = 'text/plain' + break + } + } + const headers = [] + for (const h of request.headers.entries()) { + headers.push(h) + } + const resp = { + url: request.url ?? 'empty', + method: request.method ?? 'empty', + headers: headers ?? 'empty', + body: body ?? 'empty', + } + + let responseData + if (request.headers.get('response-type') === 'blob') { + responseData = new Blob([JSON.stringify(resp)], { type: 'application/json' }) + } else { + responseData = JSON.stringify(resp) + } + return new Response(responseData, { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +})