diff --git a/Package.swift b/Package.swift index b3fb2f916..f358ad3f3 100644 --- a/Package.swift +++ b/Package.swift @@ -107,8 +107,8 @@ let package = Package( name: "Functions", dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "HTTPTypes", package: "swift-http-types"), "Helpers", + "HTTPRuntime", ] ), .testTarget( diff --git a/Sources/Functions/Deprecated.swift b/Sources/Functions/Deprecated.swift new file mode 100644 index 000000000..815b54473 --- /dev/null +++ b/Sources/Functions/Deprecated.swift @@ -0,0 +1,67 @@ +// +// Deprecated.swift +// Functions +// +// Created by Guilherme Souza on 13/07/26. +// +public import Foundation +public import Helpers + +extension FunctionsClient { + + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - headers: Additional headers to include in every request. + /// - region: The region string to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. + /// - decoder: The JSON decoder used to decode response bodies. + @_disfavoredOverload + @available( + *, deprecated, message: "Use init(url:options:) with a FunctionsClientOptions instead." + ) + 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) }, + decoder: JSONDecoder = JSONDecoder() + ) { + self.init( + url: url, + options: FunctionsClientOptions( + headers: headers, region: region, logger: logger, decoder: decoder, session: .shared), + transport: FetchHandlerTransport(fetch: fetch) + ) + } + + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - headers: Additional headers to include in every request. + /// - region: The region to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. + /// - decoder: The JSON decoder used to decode response bodies. + @available( + *, deprecated, message: "Use init(url:options:) with a FunctionsClientOptions instead." + ) + 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) }, + decoder: JSONDecoder = JSONDecoder() + ) { + self.init( + url: url, + options: FunctionsClientOptions( + headers: headers, region: region?.rawValue, logger: logger, decoder: decoder, + session: .shared), + transport: FetchHandlerTransport(fetch: fetch) + ) + } +} diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 606d311f1..df5c07f88 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,7 +1,8 @@ import ConcurrencyExtras public import Foundation -import HTTPTypes +package import HTTPRuntime public import Helpers +import IssueReporting #if canImport(FoundationNetworking) public import FoundationNetworking @@ -9,6 +10,41 @@ public import Helpers let version = Helpers.version +/// Options for configuring a ``FunctionsClient``. +public struct FunctionsClientOptions: Sendable { + /// Additional headers to include in every request. + public var headers: [String: String] + /// The region string to invoke functions in. + public var region: String? + /// A logger for request and response diagnostics. + public var logger: (any SupabaseLogger)? + /// The JSON decoder used to decode function response bodies. + public var decoder: JSONDecoder + /// The `URLSession` used to perform requests. + public var session: URLSession + + /// Creates options for configuring a ``FunctionsClient``. + /// - Parameters: + /// - headers: Additional headers to include in every request. + /// - region: The region string to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - decoder: The JSON decoder used to decode function response bodies. + /// - session: The `URLSession` used to perform requests. + public init( + headers: [String: String] = [:], + region: String? = nil, + logger: (any SupabaseLogger)? = nil, + decoder: JSONDecoder = JSONDecoder(), + session: URLSession = URLSession(configuration: .default) + ) { + self.headers = headers + self.region = region + self.logger = logger + self.decoder = decoder + self.session = session + } +} + /// A client for invoking Supabase Edge Functions. /// /// Obtain an instance from ``SupabaseClient/functions`` rather than creating one directly. @@ -27,7 +63,8 @@ let version = Helpers.version /// ## Topics /// /// ### Creating a Client -/// - ``init(url:headers:region:logger:fetch:decoder:)`` +/// - ``init(url:options:)`` +/// - ``FunctionsClientOptions`` /// - ``FetchHandler`` /// /// ### Invoking Functions @@ -61,127 +98,64 @@ public final class FunctionsClient: Sendable { struct MutableState { /// Headers to be included in the requests. - var headers = HTTPFields() + var headers: [String: String] = [:] } - private let http: any HTTPClientType private let mutableState = LockIsolated(MutableState()) - private let sessionConfiguration: URLSessionConfiguration - var headers: HTTPFields { + private let transport: any HTTPTransport + + var headers: [String: String] { mutableState.headers } /// Creates a new Functions client. /// - Parameters: /// - url: The base URL of the Functions endpoint. - /// - headers: Additional headers to include in every request. - /// - region: The region string to invoke functions in. - /// - logger: A logger for request and response diagnostics. - /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. - /// - decoder: The JSON decoder used to decode response bodies. - @_disfavoredOverload + /// - options: Options for configuring the client. 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) }, - decoder: JSONDecoder = JSONDecoder() + options: FunctionsClientOptions = FunctionsClientOptions() ) { self.init( url: url, - headers: headers, - region: region, - logger: logger, - fetch: fetch, - decoder: decoder, - sessionConfiguration: .default + options: options, + transport: URLSessionTransport(session: options.session) ) } - convenience init( + /// Internal initializer for injecting a custom transport, used for testing. + package init( url: URL, - headers: [String: String] = [:], - region: String? = nil, - logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, - decoder: JSONDecoder = JSONDecoder(), - sessionConfiguration: URLSessionConfiguration - ) { - 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, - decoder: decoder, - http: http, - sessionConfiguration: sessionConfiguration - ) - } - - init( - url: URL, - headers: [String: String], - region: String?, - decoder: JSONDecoder = JSONDecoder(), - http: any HTTPClientType, - sessionConfiguration: URLSessionConfiguration = .default + options: FunctionsClientOptions, + transport: any HTTPTransport ) { self.url = url - self.region = region - self.decoder = decoder - self.http = http - self.sessionConfiguration = sessionConfiguration + self.region = options.region + self.decoder = options.decoder + self.transport = transport mutableState.withValue { - $0.headers = HTTPFields(headers) - if $0.headers[.xClientInfo] == nil { - $0.headers[.xClientInfo] = "functions-swift/\(version)" + $0.headers = options.headers + // HTTP header names are case-insensitive: don't clobber a caller-provided + // "x-client-info" (in any casing) with the default value. + let hasClientInfo = $0.headers.keys.contains { + $0.caseInsensitiveCompare("X-Client-Info") == .orderedSame + } + if !hasClientInfo { + $0.headers["X-Client-Info"] = "functions-swift/\(version)" } } } - /// Creates a new Functions client. - /// - Parameters: - /// - url: The base URL of the Functions endpoint. - /// - headers: Additional headers to include in every request. - /// - region: The region to invoke functions in. - /// - logger: A logger for request and response diagnostics. - /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. - /// - decoder: The JSON decoder used to decode response bodies. - 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) }, - decoder: JSONDecoder = JSONDecoder() - ) { - self.init( - url: url, - headers: headers, - region: region?.rawValue, - logger: logger, - fetch: fetch, - decoder: decoder - ) - } - /// Sets or clears the JWT used in the Authorization header for subsequent requests. /// - Parameter token: The JWT to send, or `nil` to remove the Authorization header. public func setAuth(token: String?) { mutableState.withValue { if let token { - $0.headers[.authorization] = "Bearer \(token)" + $0.headers["Authorization"] = "Bearer \(token)" } else { - $0.headers[.authorization] = nil + $0.headers["Authorization"] = nil } } } @@ -199,10 +173,10 @@ public final class FunctionsClient: Sendable { options: FunctionInvokeOptions = .init(), decode: (Data, HTTPURLResponse) throws -> Response ) async throws -> Response { - let response = try await rawInvoke( + let (data, response) = try await rawInvoke( functionName: functionName, invokeOptions: options ) - return try decode(response.data, response.underlyingResponse) + return try decode(data, response) } /// Invokes a function and JSON-decodes the response body into `T`. @@ -239,20 +213,34 @@ public final class FunctionsClient: Sendable { private func rawInvoke( functionName: String, invokeOptions: FunctionInvokeOptions - ) async throws -> Helpers.HTTPResponse { + ) async throws -> (data: Data, response: HTTPURLResponse) { let request = buildRequest(functionName: functionName, options: invokeOptions) - let response = try await http.send(request) - guard 200..<300 ~= response.statusCode else { - throw FunctionsError.httpError(code: response.statusCode, data: response.data) + let response: HTTPRuntime.HTTPResponse + do { + response = try await transport.send(request) + } catch HTTPRuntime.HTTPError.transport(let underlying) { + throw underlying } - let isRelayError = response.headers[.xRelayError] == "true" - if isRelayError { - throw FunctionsError.relayError + guard + let httpResponse = response.head._underlyingHTTPResponse + ?? HTTPURLResponse( + url: request.url, statusCode: response.head.status, httpVersion: nil, + headerFields: response.head.headers) + else { + throw URLError(.badServerResponse) + } + + guard response.head.isSuccess else { + if response.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError + } + + throw FunctionsError.httpError(code: response.head.status, data: response.body) } - return response + return (response.body, httpResponse) } /// Invokes a function and returns its response as a stream of raw `Data` chunks. @@ -269,91 +257,94 @@ public final class FunctionsClient: Sendable { 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() + ) async throws -> AsyncThrowingStream { + let request = buildRequest(functionName: functionName, options: invokeOptions) + let response: HTTPResponseStream + + do { + response = try await transport.stream(request) + } catch HTTPRuntime.HTTPError.transport(let underlying) { + throw underlying + } catch { + throw error + } - continuation.onTermination = { _ in - task.cancel() + guard response.head.isSuccess else { + if response.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError + } - // Hold a strong reference to delegate until continuation terminates. - _ = delegate + let data = try await response.body.collect() + throw FunctionsError.httpError(code: response.head.status, data: data) } - return stream + let body = response.body + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await chunk in body { + continuation.yield(chunk) + } + continuation.finish() + } catch HTTPRuntime.HTTPError.transport(let underlying) { + continuation.finish(throwing: underlying) + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } } - private func buildRequest(functionName: String, options: FunctionInvokeOptions) - -> Helpers.HTTPRequest - { + private func buildRequest( + functionName: String, + options: FunctionInvokeOptions + ) -> HTTPRuntime.HTTPRequest { var query = options.query - var request = HTTPRequest( - url: url.appendingPathComponent(functionName), - method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, - query: query, - headers: mutableState.headers.merging(with: options.headers), - body: options.body, - timeoutInterval: FunctionsClient.requestIdleTimeout - ) + var requestHeaders = mutableState.headers.merging(options.headers) { $1 } if let region = options.region ?? region { - request.headers[.xRegion] = region + requestHeaders["x-region"] = region query.appendOrUpdate(URLQueryItem(name: "forceFunctionRegion", value: region)) - request.query = query } - return request - } -} + let requestURL = url.appendingPathComponent(functionName).appendingQueryItems(query) -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) + return HTTPRuntime.HTTPRequest( + method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, + url: requestURL, + headers: requestHeaders, + body: options.body.map { HTTPBody.data($0) }, + timeout: Self.requestIdleTimeout + ) } - 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 - } + /// Adapts the stored `fetch:` closure to `HTTPTransport`, for clients built via the + /// deprecated `fetch:`-closure initializers. The `fetch:` closure is inherently buffered + /// (it returns a complete `(Data, URLResponse)`), so it can't back real streaming — + /// `stream(_:)` falls back to a plain `URLSessionTransport` instead, independent of the + /// custom `fetch:` closure. + struct FetchHandlerTransport: HTTPTransport { + let fetch: FunctionsClient.FetchHandler + + func send( + _ request: HTTPRuntime.HTTPRequest, + uploadProgress: ProgressHandler? + ) async throws -> HTTPRuntime.HTTPResponse { + if uploadProgress != nil { + reportIssue( + "Upload progress is not supported with a custom fetch handler." + ) + } + let urlRequest = URLSessionTransport.makeURLRequest(request) + let (data, response) = try await fetch(urlRequest) - guard 200..<300 ~= httpResponse.statusCode else { - let error = FunctionsError.httpError( - code: httpResponse.statusCode, - data: Data() - ) - continuation.finish(throwing: error) - return + return HTTPRuntime.HTTPResponse( + head: URLSessionTransport.makeHead(response), body: data) } - let isRelayError = httpResponse.value(forHTTPHeaderField: "x-relay-error") == "true" - if isRelayError { - continuation.finish(throwing: FunctionsError.relayError) + func stream(_ request: HTTPRuntime.HTTPRequest) async throws -> HTTPResponseStream { + try await URLSessionTransport().stream(request) } } } diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index b2d19d734..6b8a20eb2 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -1,5 +1,5 @@ public import Foundation -import HTTPTypes +import HTTPRuntime import Helpers /// An error type representing various errors that can occur while invoking functions. @@ -25,7 +25,7 @@ public struct FunctionInvokeOptions: Sendable { /// Method to use in the function invocation. let method: Method? /// Headers to be included in the function invocation. - let headers: HTTPFields + let headers: [String: String] /// Body data to be sent with the function invocation. let body: Data? /// The Region to invoke the function in. @@ -51,22 +51,22 @@ public struct FunctionInvokeOptions: Sendable { body: some Encodable, encoder: JSONEncoder = JSONEncoder() ) { - var defaultHeaders = HTTPFields() + var defaultHeaders: [String: String] = [:] switch body { case let string as String: - defaultHeaders[.contentType] = "text/plain" + defaultHeaders["Content-Type"] = "text/plain" self.body = string.data(using: .utf8) case let data as Data: - defaultHeaders[.contentType] = "application/octet-stream" + defaultHeaders["Content-Type"] = "application/octet-stream" self.body = data default: - defaultHeaders[.contentType] = "application/json" + defaultHeaders["Content-Type"] = "application/json" self.body = try? encoder.encode(body) } self.method = method - self.headers = defaultHeaders.merging(with: HTTPFields(headers)) + self.headers = defaultHeaders.merging(headers) { $1 } self.region = region self.query = query } @@ -85,7 +85,7 @@ public struct FunctionInvokeOptions: Sendable { region: String? = nil ) { self.method = method - self.headers = HTTPFields(headers) + self.headers = headers self.region = region self.query = query body = nil @@ -105,7 +105,7 @@ public struct FunctionInvokeOptions: Sendable { case delete = "DELETE" } - static func httpMethod(_ method: Method?) -> HTTPTypes.HTTPRequest.Method? { + static func httpMethod(_ method: Method?) -> HTTPMethod? { switch method { case .get: .get diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift index 84ecf7f04..0954e4e1c 100644 --- a/Sources/HTTPRuntime/HTTPRequest.swift +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -24,17 +24,20 @@ package struct HTTPRequest: Sendable { package var url: URL package var headers: [String: String] package var body: HTTPBody? + package var timeout: TimeInterval? package init( method: HTTPMethod, url: URL, headers: [String: String] = [:], - body: HTTPBody? = nil + body: HTTPBody? = nil, + timeout: TimeInterval? = nil ) { self.method = method self.url = url self.headers = headers self.body = body + self.timeout = timeout } } diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift index 788ed2056..237265061 100644 --- a/Sources/HTTPRuntime/HTTPResponse.swift +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -15,6 +15,9 @@ package struct HTTPResponseHead: Sendable { package let status: Int package let headers: [String: String] + /// The underlying `HTTPURLResponse` that was used to create this response, if any. + package var _underlyingHTTPResponse: HTTPURLResponse? + package init(status: Int, headers: [String: String]) { self.status = status self.headers = headers @@ -27,6 +30,7 @@ package struct HTTPResponseHead: Sendable { return headers.first { $0.key.lowercased() == lowered }?.value } + /// Returns `true` if the status code is in the 2xx range. package var isSuccess: Bool { (200..<300).contains(status) } } @@ -52,3 +56,13 @@ package struct HTTPResponseStream: Sendable { self.body = body } } + +extension AsyncThrowingStream where Element == Data, Failure == any Error { + package func collect() async throws -> Data { + var result = Data() + for try await chunk in self { + result.append(chunk) + } + return result + } +} diff --git a/Sources/HTTPRuntime/HTTPTransport.swift b/Sources/HTTPRuntime/HTTPTransport.swift index e60db5b03..c4e2f7225 100644 --- a/Sources/HTTPRuntime/HTTPTransport.swift +++ b/Sources/HTTPRuntime/HTTPTransport.swift @@ -4,21 +4,23 @@ // // Created by Guilherme Souza on 08/07/26. // + +import Foundation + /// The abstraction generated clients depend on. Kept deliberately small so the /// generated code never touches `URLSession` directly and so tests can inject a /// mock transport. package protocol HTTPTransport: Sendable { /// Buffered request/response. - func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) - -> HTTPResponse + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse /// Streaming response: head first, body as an async sequence of chunks. /// Used for large downloads and event streams. - func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream + func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream } extension HTTPTransport { - package func send(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponse { + package func send(_ request: HTTPRequest) async throws -> HTTPResponse { try await send(request, uploadProgress: nil) } } diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 4d7d111ca..e6db1e07b 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -27,36 +27,35 @@ package import Foundation package struct URLSessionTransport: HTTPTransport { private let session: URLSession - package init(configuration: URLSessionConfiguration = .default) { + package init( + configuration: URLSessionConfiguration = .default + ) { self.session = URLSession(configuration: configuration) } - package init(session: URLSession) { + package init( + session: URLSession + ) { self.session = session } package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) - async throws(HTTPError) -> HTTPResponse + async throws -> HTTPResponse { let urlRequest = Self.makeURLRequest(request) let delegate = uploadProgress.map { ProgressDelegate(onProgress: $0) } - let data: Data - let response: URLResponse - do { + let (data, response) = switch request.body { case nil: - (data, response) = try await session.data(for: urlRequest, delegate: delegate) + try await session.data(for: urlRequest, delegate: delegate) case .data(let payload): - (data, response) = try await session.upload( + try await session.upload( for: urlRequest, from: payload, delegate: delegate) case .file(let fileURL): - (data, response) = try await session.upload( + try await session.upload( for: urlRequest, fromFile: fileURL, delegate: delegate) } - } catch { - throw HTTPError.transport(error) - } return HTTPResponse(head: Self.makeHead(response), body: data) } @@ -64,15 +63,10 @@ package struct URLSessionTransport: HTTPTransport { // swift-corelibs-foundation has no async byte-streaming API // (`bytes(for:)`/`AsyncBytes`), so on Linux the response is buffered in // full and delivered as a single chunk instead of streamed incrementally. - package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { let urlRequest = Self.makeURLRequest(request) - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: urlRequest) - } catch { - throw HTTPError.transport(error) - } + let (data, response) = try await session.data(for: urlRequest) + let body = AsyncThrowingStream { continuation in continuation.yield(data) continuation.finish() @@ -80,15 +74,9 @@ package struct URLSessionTransport: HTTPTransport { return HTTPResponseStream(head: Self.makeHead(response), body: body) } #else - package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { let urlRequest = Self.makeURLRequest(request) - let bytes: URLSession.AsyncBytes - let response: URLResponse - do { - (bytes, response) = try await session.bytes(for: urlRequest) - } catch { - throw HTTPError.transport(error) - } + let (bytes, response) = try await session.bytes(for: urlRequest) let body = AsyncThrowingStream { continuation in let task = Task { @@ -118,7 +106,7 @@ package struct URLSessionTransport: HTTPTransport { // MARK: - Helpers - private static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { + package static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { var urlRequest = URLRequest(url: request.url) urlRequest.httpMethod = request.method.rawValue for (name, value) in request.headers { @@ -127,10 +115,13 @@ package struct URLSessionTransport: HTTPTransport { if case .data(let payload) = request.body { urlRequest.httpBody = payload } + if let timeout = request.timeout { + urlRequest.timeoutInterval = timeout + } return urlRequest } - private static func makeHead(_ response: URLResponse) -> HTTPResponseHead { + package static func makeHead(_ response: URLResponse) -> HTTPResponseHead { guard let http = response as? HTTPURLResponse else { return HTTPResponseHead(status: 0, headers: [:]) } @@ -140,7 +131,9 @@ package struct URLSessionTransport: HTTPTransport { headers[key] = value } } - return HTTPResponseHead(status: http.statusCode, headers: headers) + var head = HTTPResponseHead(status: http.statusCode, headers: headers) + head._underlyingHTTPResponse = http + return head } } diff --git a/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift b/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift index 909280322..21eac9837 100644 --- a/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift +++ b/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift @@ -1,4 +1,4 @@ -import HTTPTypes +import HTTPRuntime import XCTest @testable import Functions @@ -6,13 +6,13 @@ import XCTest final class FunctionInvokeOptionsTests: XCTestCase { func test_initWithStringBody() { let options = FunctionInvokeOptions(body: "string value") - XCTAssertEqual(options.headers[.contentType], "text/plain") + XCTAssertEqual(options.headers["Content-Type"], "text/plain") XCTAssertNotNil(options.body) } func test_initWithDataBody() { let options = FunctionInvokeOptions(body: "binary value".data(using: .utf8)!) - XCTAssertEqual(options.headers[.contentType], "application/octet-stream") + XCTAssertEqual(options.headers["Content-Type"], "application/octet-stream") XCTAssertNotNil(options.body) } @@ -21,7 +21,7 @@ final class FunctionInvokeOptionsTests: XCTestCase { let value: String } let options = FunctionInvokeOptions(body: Body(value: "value")) - XCTAssertEqual(options.headers[.contentType], "application/json") + XCTAssertEqual(options.headers["Content-Type"], "application/json") XCTAssertNotNil(options.body) } @@ -34,7 +34,7 @@ final class FunctionInvokeOptionsTests: XCTestCase { encoder.keyEncodingStrategy = .convertToSnakeCase let options = FunctionInvokeOptions(body: Body(userName: "test"), encoder: encoder) - XCTAssertEqual(options.headers[.contentType], "application/json") + XCTAssertEqual(options.headers["Content-Type"], "application/json") let json = try! JSONSerialization.jsonObject(with: options.body!) as! [String: Any] XCTAssertNotNil(json["user_name"]) @@ -48,12 +48,12 @@ final class FunctionInvokeOptionsTests: XCTestCase { headers: ["Content-Type": contentType], body: "binary value".data(using: .utf8)! ) - XCTAssertEqual(options.headers[.contentType], contentType) + XCTAssertEqual(options.headers["Content-Type"], contentType) XCTAssertNotNil(options.body) } func testMethod() { - let testCases: [FunctionInvokeOptions.Method: HTTPTypes.HTTPRequest.Method] = [ + let testCases: [FunctionInvokeOptions.Method: HTTPMethod] = [ .get: .get, .post: .post, .put: .put, diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index ae72229f3..baebb621f 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -1,5 +1,5 @@ import ConcurrencyExtras -import HTTPTypes +import HTTPRuntime import InlineSnapshotTesting import Mocker import TestHelpers @@ -28,14 +28,11 @@ final class FunctionsClientTests: XCTestCase { lazy var sut = FunctionsClient( url: url, - headers: [ - "apikey": apiKey - ], - region: region, - fetch: { request in - try await self.session.data(for: request) - }, - sessionConfiguration: sessionConfiguration + options: FunctionsClientOptions( + headers: ["apikey": apiKey], + region: region + ), + transport: URLSessionTransport(session: session) ) override func setUp() { @@ -51,8 +48,8 @@ final class FunctionsClientTests: XCTestCase { ) XCTAssertEqual(client.region, "sa-east-1") - XCTAssertEqual(client.headers[.init("apikey")!], apiKey) - XCTAssertNotNil(client.headers[.init("X-Client-Info")!]) + XCTAssertEqual(client.headers["apikey"], apiKey) + XCTAssertNotNil(client.headers["X-Client-Info"]) } func testInitWithCustomDecoder() async { @@ -298,7 +295,7 @@ final class FunctionsClientTests: XCTestCase { func testInvoke_shouldThrow_FunctionsError_relayError() async { Mock( url: url.appendingPathComponent("hello_world"), - statusCode: 200, + statusCode: 300, data: [.post: Data()], additionalHeaders: [ "x-relay-error": "true" @@ -326,10 +323,10 @@ final class FunctionsClientTests: XCTestCase { func test_setAuth() { sut.setAuth(token: "access.token") - XCTAssertEqual(sut.headers[.authorization], "Bearer access.token") + XCTAssertEqual(sut.headers["Authorization"], "Bearer access.token") sut.setAuth(token: nil) - XCTAssertNil(sut.headers[.authorization]) + XCTAssertNil(sut.headers["Authorization"]) } func testInvokeWithStreamedResponse() async throws { @@ -349,7 +346,7 @@ final class FunctionsClientTests: XCTestCase { } .register() - let stream = sut._invokeWithStreamedResponse("stream") + let stream = try await sut._invokeWithStreamedResponse("stream") for try await value in stream { XCTAssertEqual(String(decoding: value, as: UTF8.self), "hello world") @@ -373,9 +370,8 @@ final class FunctionsClientTests: XCTestCase { } .register() - let stream = sut._invokeWithStreamedResponse("stream") - do { + let stream = try await sut._invokeWithStreamedResponse("stream") for try await _ in stream { XCTFail("should throw error") } @@ -387,7 +383,7 @@ final class FunctionsClientTests: XCTestCase { func testInvokeWithStreamedResponseRelayError() async throws { Mock( url: url.appendingPathComponent("stream"), - statusCode: 200, + statusCode: 300, data: [.post: Data()], additionalHeaders: [ "x-relay-error": "true" @@ -404,9 +400,8 @@ final class FunctionsClientTests: XCTestCase { } .register() - let stream = sut._invokeWithStreamedResponse("stream") - do { + let stream = try await sut._invokeWithStreamedResponse("stream") for try await _ in stream { XCTFail("should throw error") } diff --git a/Tests/SupabaseTests/SupabaseClientTests.swift b/Tests/SupabaseTests/SupabaseClientTests.swift index 59698bc2a..49c982716 100644 --- a/Tests/SupabaseTests/SupabaseClientTests.swift +++ b/Tests/SupabaseTests/SupabaseClientTests.swift @@ -118,7 +118,7 @@ struct SupabaseClientTests { """ } expectNoDifference(client.headers, client.auth.configuration.headers) - expectNoDifference(client.headers, client.functions.headers.dictionary) + expectNoDifference(client.headers, client.functions.headers) expectNoDifference(client.headers, client.storage.configuration.headers) expectNoDifference(client.headers, client.rest.configuration.headers) diff --git a/docs/superpowers/plans/2026-07-11-functions-httpruntime-migration.md b/docs/superpowers/plans/2026-07-11-functions-httpruntime-migration.md new file mode 100644 index 000000000..de4c0191d --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-functions-httpruntime-migration.md @@ -0,0 +1,1001 @@ +# Functions → HTTPRuntime Migration 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:** Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/`Helpers.HTTPRequest`/`Helpers.HTTPResponse` to the new `HTTPRuntime` target, with zero changes to `FunctionsClient`'s public API. + +**Architecture:** A private `FetchHandlerTransport` adapts the stored `fetch:` closure to `HTTPRuntime.HTTPTransport` for the buffered `invoke*` path. The streaming path (`_invokeWithStreamedResponse`) swaps its custom `URLSession` + delegate for `HTTPRuntime.URLSessionTransport.stream(_:)` directly. Headers move from `HTTPTypes.HTTPFields` to plain `[String: String]` throughout. + +**Tech Stack:** Swift 6.1, `HTTPRuntime` (already merged in this branch), existing `Helpers`/`ConcurrencyExtras` dependencies. + +## Global Constraints + +- Zero changes to `FunctionsClient`'s public API: `FetchHandler` typealias, both public initializers, `invoke`/`invoke(decode:)`/`invoke(decoder:)`, `_invokeWithStreamedResponse`, `setAuth`, every `FunctionsError` case — all stay exactly as today. +- No promotion of `HTTPRuntime` from `package` to `public` access. +- No changes to `Sources/Helpers` — `HTTPClientType`/`HTTPClient`/`LoggerInterceptor`/`Helpers.HTTPRequest`/`Helpers.HTTPResponse` stay exactly as they are (Auth, PostgREST, Realtime, Storage still depend on them). +- No test-framework migration — `Tests/FunctionsTests` stays on XCTest + Mocker. +- Request/response logging (`logger:` parameter) is dropped for now — the parameter stays in the public API but becomes inert. Not a bug, a deliberate deferred scope cut (see spec). +- The streaming path's 150s `requestIdleTimeout` is dropped for now — it falls back to `sessionConfiguration`'s default timeout (~60s). Deliberate deferred scope cut (see spec). +- `HTTPError.transport(underlying)` must never leak to callers — `FunctionsClient` catches it at both call sites and re-throws/finishes with `underlying` directly. This is verified by an existing test (`testInvoke_shouldThrow_URLError_badServerResponse`), not just a design intention. +- `invoke(_:options:decode:)`'s `decode` closure keeps its exact `(Data, HTTPURLResponse) throws -> Response` signature — synthesize the `HTTPURLResponse` from `HTTPResponseHead` + the request URL. +- Spec: `docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md` — read it for full rationale; this plan implements it exactly. + +--- + +## File Structure + +- `Package.swift` — `Functions` target: drop `HTTPTypes` product dependency, add `HTTPRuntime` target dependency. +- `Sources/Functions/Types.swift` — `FunctionInvokeOptions.headers` becomes `[String: String]`; `httpMethod(_:)` returns `HTTPRuntime.HTTPMethod?`. +- `Sources/Functions/FunctionsClient.swift` — `MutableState.headers` becomes `[String: String]`; adds private `FetchHandlerTransport`; `buildRequest` returns `HTTPRuntime.HTTPRequest`; `rawInvoke` and `_invokeWithStreamedResponse` rewritten; `StreamResponseDelegate` deleted. +- `Tests/FunctionsTests/FunctionInvokeOptionsTests.swift` — `import HTTPTypes` → `import HTTPRuntime`; `.contentType` subscript → `"Content-Type"` string key; `testMethod()`'s expected type → `HTTPMethod`. +- `Tests/FunctionsTests/FunctionsClientTests.swift` — remove the dead `import HTTPTypes` line. + +No new files. `Tests/FunctionsTests/RequestTests.swift` and `Tests/FunctionsTests/FunctionsErrorTests.swift` need no changes — they don't reference `HTTPTypes`/`HTTPFields`. + +--- + +### Task 1: Migrate headers + buffered `invoke` path to HTTPRuntime + +**Files:** +- Modify: `Package.swift` +- Modify: `Sources/Functions/Types.swift` +- Modify: `Sources/Functions/FunctionsClient.swift` (headers, initializers, `buildRequest`, `FetchHandlerTransport`, `rawInvoke`, `invoke(decode:)`; leaves `_invokeWithStreamedResponse`/`StreamResponseDelegate` using a one-line stopgap, fully migrated in Task 2) +- Modify: `Tests/FunctionsTests/FunctionInvokeOptionsTests.swift` +- Modify: `Tests/FunctionsTests/FunctionsClientTests.swift` + +**Interfaces:** +- Consumes: `HTTPRuntime.HTTPMethod` (`.get`/`.post`/`.put`/`.patch`/`.delete`/`.head`), `HTTPRequest(method:url:headers:body:)`, `HTTPBody.data(Data)`, `HTTPTransport` protocol (`send(_:uploadProgress:) async throws(HTTPError) -> HTTPResponse`, plus its 1-arg `send(_:)` convenience), `HTTPResponse(head:body:)`, `HTTPResponseHead(status:headers:)` with `.header(_:)` (case-insensitive lookup), `HTTPError.transport(any Error)`. +- Produces: `FunctionInvokeOptions.headers: [String: String]`, `FunctionInvokeOptions.httpMethod(_:) -> HTTPMethod?`, `FunctionsClient.buildRequest(functionName:options:) -> HTTPRequest` (used again, unmodified signature, by Task 2's streaming rewrite), `FetchHandlerTransport` (private struct with `static func makeURLRequest(_:) -> URLRequest`, also reused by Task 2's stopgap-turned-removed code). + +- [ ] **Step 1: Update `Package.swift`** + +Find the `Functions` target: + +```swift + .target( + name: "Functions", + dependencies: [ + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "HTTPTypes", package: "swift-http-types"), + "Helpers", + ] + ), +``` + +Replace it with: + +```swift + .target( + name: "Functions", + dependencies: [ + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "Helpers", + "HTTPRuntime", + ] + ), +``` + +- [ ] **Step 2: Rewrite `Sources/Functions/Types.swift`** + +Replace the file's entire contents with: + +```swift +public import Foundation +import HTTPRuntime +import Helpers + +/// An error type representing various errors that can occur while invoking functions. +public enum FunctionsError: Error, LocalizedError { + /// Error indicating a relay error while invoking the Edge Function. + case relayError + /// Error indicating a non-2xx status code returned by the Edge Function. + case httpError(code: Int, data: Data) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .relayError: + "Relay Error invoking the Edge Function" + case .httpError(let code, _): + "Edge Function returned a non-2xx status code: \(code)" + } + } +} + +/// Options for invoking a function. +public struct FunctionInvokeOptions: Sendable { + /// Method to use in the function invocation. + let method: Method? + /// Headers to be included in the function invocation. + let headers: [String: String] + /// Body data to be sent with the function invocation. + let body: Data? + /// The Region to invoke the function in. + let region: String? + /// The query to be included in the function invocation. + let query: [URLQueryItem] + + /// Creates options for a function invocation with an encodable body. + /// - Parameters: + /// - method: The HTTP method to use. Defaults to POST when `nil`. + /// - query: Query items appended to the function URL. + /// - headers: Additional headers to include in the request. + /// - region: The region string to invoke the function in. + /// - body: The body to encode and send. Strings are sent as `text/plain`, `Data` as + /// `application/octet-stream`, and all other `Encodable` values as JSON. + /// - encoder: The JSON encoder used when `body` is encoded as JSON. + @_disfavoredOverload + public init( + method: Method? = nil, + query: [URLQueryItem] = [], + headers: [String: String] = [:], + region: String? = nil, + body: some Encodable, + encoder: JSONEncoder = JSONEncoder() + ) { + var defaultHeaders: [String: String] = [:] + + switch body { + case let string as String: + defaultHeaders["Content-Type"] = "text/plain" + self.body = string.data(using: .utf8) + case let data as Data: + defaultHeaders["Content-Type"] = "application/octet-stream" + self.body = data + default: + defaultHeaders["Content-Type"] = "application/json" + self.body = try? encoder.encode(body) + } + + self.method = method + self.headers = defaultHeaders.merging(headers) { $1 } + self.region = region + self.query = query + } + + /// Creates options for a function invocation with no body. + /// - Parameters: + /// - method: The HTTP method to use. Defaults to POST when `nil`. + /// - query: Query items appended to the function URL. + /// - headers: Additional headers to include in the request. + /// - region: The region string to invoke the function in. + @_disfavoredOverload + public init( + method: Method? = nil, + query: [URLQueryItem] = [], + headers: [String: String] = [:], + region: String? = nil + ) { + self.method = method + self.headers = headers + self.region = region + self.query = query + body = nil + } + + /// The HTTP method to use when invoking a function. + public enum Method: String, Sendable { + /// Performs an HTTP GET request. + case get = "GET" + /// Performs an HTTP POST request. + case post = "POST" + /// Performs an HTTP PUT request. + case put = "PUT" + /// Performs an HTTP PATCH request. + case patch = "PATCH" + /// Performs an HTTP DELETE request. + case delete = "DELETE" + } + + static func httpMethod(_ method: Method?) -> HTTPMethod? { + switch method { + case .get: + .get + case .post: + .post + case .put: + .put + case .patch: + .patch + case .delete: + .delete + case nil: + nil + } + } +} + +/// A Supabase Edge Function deployment region. +public enum FunctionRegion: String, Sendable { + /// Asia Pacific (Tokyo). + case apNortheast1 = "ap-northeast-1" + /// Asia Pacific (Seoul). + case apNortheast2 = "ap-northeast-2" + /// Asia Pacific (Mumbai). + case apSouth1 = "ap-south-1" + /// Asia Pacific (Singapore). + case apSoutheast1 = "ap-southeast-1" + /// Asia Pacific (Sydney). + case apSoutheast2 = "ap-southeast-2" + /// Canada (Central). + case caCentral1 = "ca-central-1" + /// Europe (Frankfurt). + case euCentral1 = "eu-central-1" + /// Europe (Ireland). + case euWest1 = "eu-west-1" + /// Europe (London). + case euWest2 = "eu-west-2" + /// Europe (Paris). + case euWest3 = "eu-west-3" + /// South America (São Paulo). + case saEast1 = "sa-east-1" + /// US East (N. Virginia). + case usEast1 = "us-east-1" + /// US West (N. California). + case usWest1 = "us-west-1" + /// US West (Oregon). + case usWest2 = "us-west-2" +} + +extension FunctionInvokeOptions { + /// Creates options for a function invocation with an encodable body and a typed region. + /// - Parameters: + /// - method: The HTTP method to use. Defaults to POST when `nil`. + /// - headers: Additional headers to include in the request. + /// - region: The region to invoke the function in. + /// - body: The body to encode and send. + /// - encoder: The JSON encoder used when `body` is encoded as JSON. + public init( + method: Method? = nil, + headers: [String: String] = [:], + region: FunctionRegion? = nil, + body: some Encodable, + encoder: JSONEncoder = JSONEncoder() + ) { + self.init( + method: method, + headers: headers, + region: region?.rawValue, + body: body, + encoder: encoder + ) + } + + /// Creates options for a function invocation with no body and a typed region. + /// - Parameters: + /// - method: The HTTP method to use. Defaults to POST when `nil`. + /// - headers: Additional headers to include in the request. + /// - region: The region to invoke the function in. + public init( + method: Method? = nil, + headers: [String: String] = [:], + region: FunctionRegion? = nil + ) { + self.init(method: method, headers: headers, region: region?.rawValue) + } +} +``` + +Only two things changed from the original: `headers: HTTPFields` → `headers: [String: String]` (and its two call sites, `defaultHeaders["Content-Type"] = ...` / `defaultHeaders.merging(headers) { $1 }`), and `httpMethod(_:) -> HTTPTypes.HTTPRequest.Method?` → `httpMethod(_:) -> HTTPMethod?`. Everything else — doc comments, `FunctionRegion`, the typed-region convenience inits — is copied verbatim. + +- [ ] **Step 3: Rewrite the header-handling and initializer portion of `Sources/Functions/FunctionsClient.swift`** + +Replace lines 1–150 (from the top of the file through the end of the `init(url:headers:region:decoder:http:sessionConfiguration:)` initializer) with: + +```swift +import ConcurrencyExtras +public import Foundation +import HTTPRuntime +public import Helpers + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +let version = Helpers.version + +/// A client for invoking Supabase Edge Functions. +/// +/// Obtain an instance from ``SupabaseClient/functions`` rather than creating one directly. +/// +/// ```swift +/// // Invoke and decode a response +/// let order: Order = try await supabase.functions.invoke("get-order") +/// +/// // Invoke with a body and no return value +/// try await supabase.functions.invoke( +/// "send-email", +/// options: FunctionInvokeOptions(body: ["to": "user@example.com"]) +/// ) +/// ``` +/// +/// ## Topics +/// +/// ### Creating a Client +/// - ``init(url:headers:region:logger:fetch:decoder:)`` +/// - ``FetchHandler`` +/// +/// ### Invoking Functions +/// - ``invoke(_:options:decode:)`` +/// - ``invoke(_:options:decoder:)`` +/// - ``invoke(_:options:)`` +/// - ``_invokeWithStreamedResponse(_:options:)`` +/// +/// ### Configuration +/// - ``decoder`` +/// - ``requestIdleTimeout`` +/// - ``setAuth(token:)`` +public final class FunctionsClient: Sendable { + /// A handler that performs the underlying HTTP request for a function invocation. + public typealias FetchHandler = + @Sendable (_ request: URLRequest) async throws -> ( + Data, URLResponse + ) + + /// The maximum time an Edge Function may run before the gateway returns a 504 error (150 seconds). + public static let requestIdleTimeout: TimeInterval = 150 + + /// The base URL for the functions. + let url: URL + + /// The Region to invoke the functions in. + let region: String? + + /// The JSON decoder used to decode function response bodies. + public let decoder: JSONDecoder + + struct MutableState { + /// Headers to be included in the requests. + var headers: [String: String] = [:] + } + + private let fetch: FetchHandler + private let mutableState = LockIsolated(MutableState()) + private let sessionConfiguration: URLSessionConfiguration + + var headers: [String: String] { + mutableState.headers + } + + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - headers: Additional headers to include in every request. + /// - region: The region string to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. + /// - decoder: The JSON decoder used to decode response bodies. + @_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) }, + decoder: JSONDecoder = JSONDecoder() + ) { + self.init( + url: url, + headers: headers, + region: region, + fetch: fetch, + decoder: decoder, + sessionConfiguration: .default + ) + } + + convenience init( + url: URL, + headers: [String: String] = [:], + region: String? = nil, + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, + decoder: JSONDecoder = JSONDecoder(), + sessionConfiguration: URLSessionConfiguration + ) { + self.init( + url: url, + headers: headers, + region: region, + decoder: decoder, + fetch: fetch, + sessionConfiguration: sessionConfiguration + ) + } + + init( + url: URL, + headers: [String: String], + region: String?, + decoder: JSONDecoder = JSONDecoder(), + fetch: @escaping FetchHandler, + sessionConfiguration: URLSessionConfiguration = .default + ) { + self.url = url + self.region = region + self.decoder = decoder + self.fetch = fetch + self.sessionConfiguration = sessionConfiguration + + mutableState.withValue { + $0.headers = headers + if $0.headers["X-Client-Info"] == nil { + $0.headers["X-Client-Info"] = "functions-swift/\(version)" + } + } + } +``` + +Note: the `logger:` parameter is dropped from the internal `convenience init(...sessionConfiguration:)` and the innermost `init(...)` — only the two *public*-facing initializers still accept it (per the "logging dropped for now" constraint), and they simply don't forward it anywhere anymore. + +- [ ] **Step 4: Update `setAuth` and the typed-region public initializer** + +Find: + +```swift + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - headers: Additional headers to include in every request. + /// - region: The region to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. + /// - decoder: The JSON decoder used to decode response bodies. + 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) }, + decoder: JSONDecoder = JSONDecoder() + ) { + self.init( + url: url, + headers: headers, + region: region?.rawValue, + logger: logger, + fetch: fetch, + decoder: decoder + ) + } + + /// Sets or clears the JWT used in the Authorization header for subsequent requests. + /// - Parameter token: The JWT to send, or `nil` to remove the Authorization header. + public func setAuth(token: String?) { + mutableState.withValue { + if let token { + $0.headers[.authorization] = "Bearer \(token)" + } else { + $0.headers[.authorization] = nil + } + } + } +``` + +Replace it with: + +```swift + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - headers: Additional headers to include in every request. + /// - region: The region to invoke functions in. + /// - logger: A logger for request and response diagnostics. + /// - fetch: A custom fetch handler. Defaults to `URLSession.shared`. + /// - decoder: The JSON decoder used to decode response bodies. + 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) }, + decoder: JSONDecoder = JSONDecoder() + ) { + self.init( + url: url, + headers: headers, + region: region?.rawValue, + fetch: fetch, + decoder: decoder + ) + } + + /// Sets or clears the JWT used in the Authorization header for subsequent requests. + /// - Parameter token: The JWT to send, or `nil` to remove the Authorization header. + public func setAuth(token: String?) { + mutableState.withValue { + if let token { + $0.headers["Authorization"] = "Bearer \(token)" + } else { + $0.headers["Authorization"] = nil + } + } + } +``` + +- [ ] **Step 5: Rewrite `rawInvoke`, `buildRequest`, and add `FetchHandlerTransport`** + +Find: + +```swift + private func rawInvoke( + functionName: String, + invokeOptions: FunctionInvokeOptions + ) async throws -> Helpers.HTTPResponse { + let request = buildRequest(functionName: functionName, options: invokeOptions) + let response = try await http.send(request) + + guard 200..<300 ~= response.statusCode else { + throw FunctionsError.httpError(code: response.statusCode, data: response.data) + } + + let isRelayError = response.headers[.xRelayError] == "true" + if isRelayError { + throw FunctionsError.relayError + } + + return response + } +``` + +Replace it with: + +```swift + private func rawInvoke( + functionName: String, + invokeOptions: FunctionInvokeOptions + ) async throws -> (data: Data, response: HTTPURLResponse) { + let request = buildRequest(functionName: functionName, options: invokeOptions) + let transport = FetchHandlerTransport(fetch: fetch) + + let response: HTTPResponse + do { + response = try await transport.send(request) + } catch HTTPError.transport(let underlying) { + throw underlying + } + + guard + let httpResponse = HTTPURLResponse( + url: request.url, statusCode: response.head.status, httpVersion: nil, + headerFields: response.head.headers) + else { + throw URLError(.badServerResponse) + } + + guard 200..<300 ~= response.head.status else { + throw FunctionsError.httpError(code: response.head.status, data: response.body) + } + + if response.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError + } + + return (response.body, httpResponse) + } +``` + +Find: + +```swift + private func buildRequest(functionName: String, options: FunctionInvokeOptions) + -> Helpers.HTTPRequest + { + var query = options.query + var request = HTTPRequest( + url: url.appendingPathComponent(functionName), + method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, + query: query, + headers: mutableState.headers.merging(with: options.headers), + body: options.body, + timeoutInterval: FunctionsClient.requestIdleTimeout + ) + + if let region = options.region ?? region { + request.headers[.xRegion] = region + query.appendOrUpdate(URLQueryItem(name: "forceFunctionRegion", value: region)) + request.query = query + } + + return request + } +} +``` + +Replace it with: + +```swift + private func buildRequest(functionName: String, options: FunctionInvokeOptions) -> HTTPRequest { + var query = options.query + var requestHeaders = mutableState.headers.merging(options.headers) { $1 } + + if let region = options.region ?? region { + requestHeaders["x-region"] = region + query.appendOrUpdate(URLQueryItem(name: "forceFunctionRegion", value: region)) + } + + let requestURL = url.appendingPathComponent(functionName).appendingQueryItems(query) + + return HTTPRequest( + method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, + url: requestURL, + headers: requestHeaders, + body: options.body.map { HTTPBody.data($0) } + ) + } + + /// Adapts the stored `fetch:` closure to `HTTPTransport` for the buffered `invoke*` path. + /// Only `send(_:uploadProgress:)` is used — streaming always goes through + /// `URLSessionTransport` directly (see `_invokeWithStreamedResponse`), never through the + /// public `fetch:` closure, so `stream(_:)` here is unreachable. + private struct FetchHandlerTransport: HTTPTransport { + let fetch: FunctionsClient.FetchHandler + + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) + -> HTTPResponse + { + let urlRequest = Self.makeURLRequest(request) + let data: Data + let response: URLResponse + do { + (data, response) = try await fetch(urlRequest) + } catch { + throw HTTPError.transport(error) + } + guard let http = response as? HTTPURLResponse else { + throw HTTPError.transport(URLError(.badServerResponse)) + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + if let key = key as? String, let value = value as? String { + headers[key] = value + } + } + return HTTPResponse(head: HTTPResponseHead(status: http.statusCode, headers: headers), body: data) + } + + func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + fatalError("FetchHandlerTransport does not support streaming; use URLSessionTransport instead") + } + + static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { + var urlRequest = URLRequest(url: request.url, timeoutInterval: FunctionsClient.requestIdleTimeout) + urlRequest.httpMethod = request.method.rawValue + for (name, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + if case .data(let payload) = request.body { + urlRequest.httpBody = payload + } + return urlRequest + } + } +} +``` + +Note the closing `}` at the end — `buildRequest` and `FetchHandlerTransport` are the last members before the class closes; `_invokeWithStreamedResponse` and `StreamResponseDelegate` (further up in the file, untouched by this step) stay exactly where they are between `setAuth`/`invoke*` and this point. + +- [ ] **Step 6: Update `invoke(_:options:decode:)`'s call site** + +Find: + +```swift + 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 + ) + return try decode(response.data, response.underlyingResponse) + } +``` + +Replace it with: + +```swift + public func invoke( + _ functionName: String, + options: FunctionInvokeOptions = .init(), + decode: (Data, HTTPURLResponse) throws -> Response + ) async throws -> Response { + let (data, response) = try await rawInvoke( + functionName: functionName, invokeOptions: options + ) + return try decode(data, response) + } +``` + +- [ ] **Step 7: Apply the one-line stopgap so `_invokeWithStreamedResponse` still compiles (it's fully migrated in Task 2)** + +Find, inside `_invokeWithStreamedResponse` (this method itself is untouched otherwise in this task): + +```swift + let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest +``` + +Replace it with: + +```swift + let urlRequest = FetchHandlerTransport.makeURLRequest( + buildRequest(functionName: functionName, options: invokeOptions)) +``` + +`HTTPRuntime.HTTPRequest` (which `buildRequest` now returns) has no `.urlRequest` convenience property the way `Helpers.HTTPRequest` did — `FetchHandlerTransport.makeURLRequest` is the equivalent conversion, already written in Step 5. Everything else in `_invokeWithStreamedResponse` and all of `StreamResponseDelegate` stay exactly as they are until Task 2. + +- [ ] **Step 8: Fix `Tests/FunctionsTests/FunctionInvokeOptionsTests.swift`** + +Replace the file's entire contents with: + +```swift +import HTTPRuntime +import XCTest + +@testable import Functions + +final class FunctionInvokeOptionsTests: XCTestCase { + func test_initWithStringBody() { + let options = FunctionInvokeOptions(body: "string value") + XCTAssertEqual(options.headers["Content-Type"], "text/plain") + XCTAssertNotNil(options.body) + } + + func test_initWithDataBody() { + let options = FunctionInvokeOptions(body: "binary value".data(using: .utf8)!) + XCTAssertEqual(options.headers["Content-Type"], "application/octet-stream") + XCTAssertNotNil(options.body) + } + + func test_initWithEncodableBody() { + struct Body: Encodable { + let value: String + } + let options = FunctionInvokeOptions(body: Body(value: "value")) + XCTAssertEqual(options.headers["Content-Type"], "application/json") + XCTAssertNotNil(options.body) + } + + func test_initWithEncodableBodyAndCustomEncoder() { + struct Body: Encodable { + let userName: String + } + + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + let options = FunctionInvokeOptions(body: Body(userName: "test"), encoder: encoder) + XCTAssertEqual(options.headers["Content-Type"], "application/json") + + let json = try! JSONSerialization.jsonObject(with: options.body!) as! [String: Any] + XCTAssertNotNil(json["user_name"]) + XCTAssertNil(json["userName"]) + } + + func test_initWithCustomContentType() { + let boundary = "Boundary-\(UUID().uuidString)" + let contentType = "multipart/form-data; boundary=\(boundary)" + let options = FunctionInvokeOptions( + headers: ["Content-Type": contentType], + body: "binary value".data(using: .utf8)! + ) + XCTAssertEqual(options.headers["Content-Type"], contentType) + XCTAssertNotNil(options.body) + } + + func testMethod() { + let testCases: [FunctionInvokeOptions.Method: HTTPMethod] = [ + .get: .get, + .post: .post, + .put: .put, + .patch: .patch, + .delete: .delete, + ] + + for (method, expected) in testCases { + XCTAssertEqual(FunctionInvokeOptions.httpMethod(method), expected) + } + } +} +``` + +`HTTPRuntime` resolves here without any `Package.swift` change to the `FunctionsTests` target — it's transitively available via `Functions`'s new dependency on it, the same mechanism that made the old `import HTTPTypes` resolve here before without an explicit product entry. + +- [ ] **Step 9: Remove the dead import in `Tests/FunctionsTests/FunctionsClientTests.swift`** + +Find line 2: + +```swift +import HTTPTypes +``` + +Delete it. Nothing else in that file references `HTTPTypes` (confirmed: `Mock(... data: [.post: ...])`'s `.post` resolves to `Mocker`'s own `HTTPMethod` enum, not `HTTPTypes`). + +- [ ] **Step 10: Run the full existing test suite** + +This is a refactor of already-tested code, not new functionality — there's no new test to write first. The existing suite is the correctness oracle; the goal of this step is confirming it still passes unmodified against the new implementation. + +Run: `swift test --filter FunctionsTests` +Expected: all tests in `FunctionsClientTests`, `RequestTests`, `FunctionInvokeOptionsTests`, `FunctionsErrorTests` pass, with no snapshot mismatches. + +If a curl-snapshot test (`FunctionsClientTests.swift` or `RequestTests.swift`) fails with a text diff, do not re-record it — per the spec, that means the `HTTPRequest ↔ URLRequest` conversion introduced a real difference (header casing, query order, body encoding) that needs to be fixed in `FetchHandlerTransport.makeURLRequest` or `buildRequest`, not papered over. If `testInvoke_shouldThrow_URLError_badServerResponse` fails, check that the `catch HTTPError.transport(let underlying) { throw underlying }` unwrap in `rawInvoke` (Step 5) is in place and executes before the status-code check. + +- [ ] **Step 11: Run the full package build** + +Run: `swift build` +Expected: clean build, no warnings from `Functions`. + +- [ ] **Step 12: Commit** + +```bash +git add Package.swift Sources/Functions/Types.swift Sources/Functions/FunctionsClient.swift Tests/FunctionsTests/FunctionInvokeOptionsTests.swift Tests/FunctionsTests/FunctionsClientTests.swift +git commit -m "refactor(functions): migrate headers and buffered invoke path to HTTPRuntime" +``` + +--- + +### Task 2: Migrate the streaming path to `URLSessionTransport`, remove `StreamResponseDelegate` + +**Files:** +- Modify: `Sources/Functions/FunctionsClient.swift` (`_invokeWithStreamedResponse` rewritten; `StreamResponseDelegate` class deleted) + +**Interfaces:** +- Consumes: `HTTPRuntime.URLSessionTransport(configuration:)`, `.stream(_:) async throws(HTTPError) -> HTTPResponseStream`, `HTTPResponseStream.head: HTTPResponseHead` / `.body: AsyncThrowingStream`, `HTTPResponseHead.status`/`.header(_:)`, `HTTPError.transport(any Error)`. `buildRequest(functionName:options:) -> HTTPRequest` (from Task 1, unchanged signature). +- Produces: nothing new — `_invokeWithStreamedResponse`'s public signature is unchanged (`(_ functionName: String, options: FunctionInvokeOptions) -> AsyncThrowingStream`, no `async`, no `throws`). + +- [ ] **Step 1: Replace `_invokeWithStreamedResponse` and delete `StreamResponseDelegate`** + +Find (this is the rest of the file from Task 1's Step 7 stopgap through the end of the file): + +```swift + 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 = FetchHandlerTransport.makeURLRequest( + buildRequest(functionName: functionName, options: invokeOptions)) + + let task = session.dataTask(with: urlRequest) + task.resume() + + continuation.onTermination = { _ in + task.cancel() + + // Hold a strong reference to delegate until continuation terminates. + _ = delegate + } + + return stream + } +``` + +(the code above already reflects Task 1's Step 7 stopgap edit — replace it with:) + +```swift + public func _invokeWithStreamedResponse( + _ functionName: String, + options invokeOptions: FunctionInvokeOptions = .init() + ) -> AsyncThrowingStream { + let request = buildRequest(functionName: functionName, options: invokeOptions) + let transport = URLSessionTransport(configuration: sessionConfiguration) + + let (stream, continuation) = AsyncThrowingStream.makeStream() + + let task = Task { + do { + let responseStream = try await transport.stream(request) + + guard 200..<300 ~= responseStream.head.status else { + throw FunctionsError.httpError(code: responseStream.head.status, data: Data()) + } + if responseStream.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError + } + + for try await chunk in responseStream.body { + continuation.yield(chunk) + } + continuation.finish() + } catch HTTPError.transport(let underlying) { + continuation.finish(throwing: underlying) + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in task.cancel() } + + return stream + } +``` + +Then find, at the bottom of the file, and delete entirely: + +```swift +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 isRelayError = httpResponse.value(forHTTPHeaderField: "x-relay-error") == "true" + if isRelayError { + continuation.finish(throwing: FunctionsError.relayError) + } + } +} +``` + +The file now ends with the closing `}` of `FetchHandlerTransport` (from Task 1's Step 5). + +- [ ] **Step 2: Run the streaming-specific tests** + +This is a refactor of already-tested code — the goal is confirming existing streaming tests still pass, not writing new ones. + +Run: `swift test --filter FunctionsTests.FunctionsClientTests/testInvokeWithStreamedResponse` +Run: `swift test --filter FunctionsTests.FunctionsClientTests/testInvokeWithStreamedResponseHTTPError` +Run: `swift test --filter FunctionsTests.FunctionsClientTests/testInvokeWithStreamedResponseRelayError` +Expected: all three pass. + +- [ ] **Step 3: Run the full `FunctionsTests` suite** + +Run: `swift test --filter FunctionsTests` +Expected: all tests pass (no regressions in the buffered-path tests from Task 1). + +- [ ] **Step 4: Run the full package build** + +Run: `swift build` +Expected: clean build. Confirm `NSObject`/`URLSessionDataDelegate` are no longer referenced anywhere in `Sources/Functions/FunctionsClient.swift` (the only place that used them was the now-deleted `StreamResponseDelegate`). + +- [ ] **Step 5: Format and spell-check** + +Run: `./scripts/format.sh` +Expected: exits 0, no unexpected diffs beyond this task's own files. + +Run: `./scripts/spell-check.sh` +Expected: exits 0. If it flags a new word, add it to `dictionary.txt` under a `# Terms from the Functions HTTPRuntime migration.` section and re-run until clean. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/Functions/FunctionsClient.swift +git commit -m "refactor(functions): migrate streaming path to URLSessionTransport" +``` + +If Step 5 touched `dictionary.txt`, include it in this commit too. diff --git a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md new file mode 100644 index 000000000..fcd9cf29d --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -0,0 +1,118 @@ +# Functions → HTTPRuntime migration design + +## Goal + +Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/`Helpers.HTTPRequest`/`Helpers.HTTPResponse` (plus a hand-rolled `URLSession`+delegate for streaming) to the new `HTTPRuntime` target, without changing `FunctionsClient`'s public API in any way. + +`HTTPRuntime` was originally built for a codegen pipeline (see `docs/superpowers/specs/2026-07-11-http-runtime-test-helpers-design.md`); this is its first adoption by a hand-written client. No other module (Auth, PostgREST, Storage, Realtime, Supabase) uses it yet. + +## Non-goals + +- No public API changes to `FunctionsClient`: the `FetchHandler` typealias (`(URLRequest) async throws -> (Data, URLResponse)`), both public initializers, `invoke`/`invoke(decode:)`/`invoke(decoder:)`, `_invokeWithStreamedResponse`, `setAuth`, and every `FunctionsError` case stay exactly as they are today. +- No promotion of `HTTPRuntime` from `package` to `public` access — this migration is entirely internal to the `Functions` target. +- No test-framework migration. `Tests/FunctionsTests` stays on XCTest + Mocker; adopting Swift Testing / `HTTPRuntimeTestHelpers` for this module is the separate SDK-435 migration track, out of scope here. +- No new multipart/file-upload support in Functions — it doesn't use it today and doesn't need it. +- No SSE/event-stream framing — Functions' streaming already yields raw `Data` chunks (no SSE parsing), which is exactly what `HTTPRuntime.HTTPTransport.stream(_:)` already provides. +- No changes to `Sources/Helpers` — `HTTPClientType`/`HTTPClient`/`LoggerInterceptor`/`Helpers.HTTPRequest`/`Helpers.HTTPResponse` stay exactly as they are, since Auth, PostgREST, Realtime, and Storage all still depend on them. Functions simply stops calling them; nothing about them changes. +- **Request/response logging is dropped for now, to be revisited later.** `LoggerInterceptor`'s verbose request/response logging (see "Current state" below) is not reimplemented against `HTTPRuntime` types in this migration. The public `logger:` initializer parameter stays (no public API change), but it becomes inert — supplying a logger no longer produces any log output. This is a deliberate, temporary regression, not an oversight; re-adding equivalent logging directly against `HTTPRuntime.HTTPRequest`/`HTTPResponse` is follow-up work, not part of this migration. +- **The streaming path's 150-second idle timeout is dropped for now, to be revisited later.** `HTTPRuntime.HTTPRequest` has no `timeoutInterval` field, and `URLSessionTransport` has no per-request timeout hook — today's `Helpers.HTTPRequest.timeoutInterval: FunctionsClient.requestIdleTimeout` (150s, matching the Edge Function gateway's own timeout) only carries over to the buffered path (`FetchHandlerTransport` builds its own `URLRequest` and can set this manually). The streaming path (`_invokeWithStreamedResponse`, already underscored/experimental) falls back to `URLSessionConfiguration`'s default request timeout (~60s) instead. This is a deliberate, temporary regression — a long-running streamed function call may now time out client-side before the gateway's own 150s limit — accepted for this migration and left for follow-up work. + +## Current state (for reference) + +- `FunctionsClient.invoke*` builds a `Helpers.HTTPRequest`, sends it via `any HTTPClientType` (`Helpers.HTTPClient`, an actor wrapping the stored `fetch:` closure with an interceptor chain), gets back a `Helpers.HTTPResponse`. +- `_invokeWithStreamedResponse` bypasses the `fetch:` closure entirely: it builds its own `URLSession(configuration: sessionConfiguration)` and a custom `URLSessionDataDelegate` (`StreamResponseDelegate`) that yields `Data` chunks into an `AsyncThrowingStream`. +- Headers are built/merged as `HTTPTypes.HTTPFields` in `Types.swift` and `FunctionsClient.swift`. +- `Functions`'s `Package.swift` dependencies: `ConcurrencyExtras`, `HTTPTypes`, `Helpers`. + +## Architecture + +### Buffered path (`invoke`, `invoke(decode:)`, `invoke(decoder:)`) + +A new private type, `FetchHandlerTransport`, adapts the stored `fetch: FetchHandler` closure to `HTTPRuntime.HTTPTransport`. Logging is intentionally not reimplemented here (see Non-goals) — `Helpers.HTTPClient`'s interceptor chain is not carried over at all; the adapter is a plain, direct pass-through: + +```swift +private struct FetchHandlerTransport: HTTPTransport { + let fetch: FunctionsClient.FetchHandler + + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) -> HTTPResponse { + let urlRequest = Self.makeURLRequest(request) + let data: Data + let response: URLResponse + do { + (data, response) = try await fetch(urlRequest) + } catch { + throw HTTPError.transport(error) + } + guard let http = response as? HTTPURLResponse else { + throw HTTPError.transport(URLError(.badServerResponse)) + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + if let key = key as? String, let value = value as? String { headers[key] = value } + } + return HTTPResponse(head: HTTPResponseHead(status: http.statusCode, headers: headers), body: data) + } + + func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + // Never called — FunctionsClient always uses URLSessionTransport directly for streaming. + fatalError("FetchHandlerTransport does not support streaming; use URLSessionTransport instead") + } +} +``` + +`invoke`/`invoke(decode:)`/`invoke(decoder:)` build an `HTTPRuntime.HTTPRequest` (method, url, headers as `[String: String]`, body as `.data(Data)`) instead of `Helpers.HTTPRequest`, construct a `FetchHandlerTransport(fetch: fetch)`, and call `.send(request, uploadProgress: nil)`. The existing status-check/error-mapping logic (non-2xx → `FunctionsError.httpError(code:data:)`, relay-error response header → `.relayError`) moves to operate on the returned `HTTPResponse` instead of `Helpers.HTTPResponse` — same checks, same error cases, different input type. The `logger:` initializer parameter is still accepted (public API unchanged) but is no longer passed anywhere or used — `FunctionsClient` doesn't need to store it. + +`invoke(_:options:decode:)`'s `decode` closure is public API and keeps its exact signature: `(Data, HTTPURLResponse) throws -> Response`. Since `HTTPRuntime.HTTPResponse` only carries a `HTTPResponseHead` (status + `[String: String]` headers), not an `HTTPURLResponse`, `rawInvoke` must synthesize one to hand to `decode`: `HTTPURLResponse(url: request.url, statusCode: response.head.status, httpVersion: nil, headerFields: response.head.headers)`. This preserves the existing call site's type exactly; nothing about `decode`'s contract changes. + +### Streaming path (`_invokeWithStreamedResponse`) + +Replaces the custom `URLSession` + `StreamResponseDelegate` (`FunctionsClient.swift:317-359`, deleted entirely) with `HTTPRuntime.URLSessionTransport(configuration: sessionConfiguration)`, built directly (same `sessionConfiguration` stored property already used today) — not through `FetchHandlerTransport`, since streaming never went through the public `fetch:` closure to begin with and continues not to. This request no longer carries the 150s `requestIdleTimeout` (see Non-goals) — it uses whatever timeout `sessionConfiguration` already specifies (default `URLSessionConfiguration.default` timeout, ~60s, unless the caller supplied a custom `sessionConfiguration` with its own value). + +```swift +let transport = URLSessionTransport(configuration: sessionConfiguration) +let responseStream = try await transport.stream(request) +// same head-status-check as today (relay-error / non-2xx), then yield responseStream.body's chunks +``` + +### Headers + +`HTTPTypes.HTTPFields` usage in `Types.swift` (`FunctionInvokeOptions.headers`) and `FunctionsClient.swift` (header merging) is replaced with plain `[String: String]` dictionary merging, matching `HTTPRuntime.HTTPRequest.headers`'s shape. The `HTTPTypes` dependency is dropped from the `Functions` target in `Package.swift`. + +### Errors + +`FunctionsError`'s two cases (`.relayError`, `.httpError(code:data:)`) are unchanged. + +**`HTTPError` must never leak to `FunctionsClient` callers — this is verified by an existing test, not just a stated goal.** `Tests/FunctionsTests/FunctionsClientTests.swift:243-269` (`testInvoke_shouldThrow_URLError_badServerResponse`) mocks the `fetch:` closure throwing a raw `URLError(.badServerResponse)` and asserts `sut.invoke(...)` throws that *exact* `URLError` — caught via `catch let urlError as URLError`. Today this works because `Helpers.HTTPClient.send` never wraps the `fetch` closure's thrown error; it propagates untouched. + +Once `FetchHandlerTransport.send` wraps that same failure as `HTTPError.transport(urlError)` (per its `send` implementation above), `rawInvoke` (buffered path) and `_invokeWithStreamedResponse` (streaming path) must catch `HTTPError.transport(let underlying)` and re-throw `underlying` itself — not the `HTTPError` wrapper — so this test (and any caller pattern-matching on the underlying error type) keeps working exactly as today. `FetchHandlerTransport`/`URLSessionTransport` never produce any other `HTTPError` case in this flow (no decoding, no generated-client status-checking), so unwrapping `.transport` is the complete fix, not a partial one. + +### Package.swift + +``` +Functions target dependencies: ConcurrencyExtras, Helpers, HTTPRuntime // HTTPTypes removed +``` + +## Data flow + +1. Caller invokes `client.invoke(...)`. +2. `FunctionsClient` builds an `HTTPRuntime.HTTPRequest` from the function name, `FunctionInvokeOptions`, base URL, and current headers/auth. +3. For the buffered path: wraps the stored `fetch:` closure in `FetchHandlerTransport`, calls `.send(_:)`, gets `HTTPResponse`, applies existing status/error checks, decodes/returns the body exactly as today. +4. For the streaming path: builds `URLSessionTransport(configuration: sessionConfiguration)` directly, calls `.stream(_:)`, gets `HTTPResponseStream` (head + `AsyncThrowingStream`), applies the same head-status-check before yielding chunks to the caller. + +## Testing + +`Tests/FunctionsTests` (XCTest + Mocker, `URLProtocol`-level interception, `InlineSnapshotTesting` curl-snapshot assertions in `RequestTests.swift` and `FunctionsClientTests.swift`) is the correctness oracle for this migration and must keep passing **without changing its own mocking mechanism** — Mocker intercepts at the `URLSession`/`URLProtocol` level, which sits below `FetchHandlerTransport`'s conversion layer and is unaffected by it, since the adapter still calls the real `fetch:` closure with a real `URLRequest`. + +If the `HTTPRequest ↔ URLRequest` round-trip introduces any incidental difference from today's `Helpers.HTTPRequest ↔ URLRequest` conversion (header key casing, query-item ordering, body encoding) that changes a recorded curl snapshot, that is a real regression in the adapter to fix — re-recording a snapshot is only acceptable when the difference is deliberate and reviewed, never as a way to make a mismatch disappear. + +No new tests are required by this migration beyond what's needed to keep the existing suite green; this is an internal refactor, not new behavior. + +Two existing test files reference the types being dropped and need small mechanical updates (not new coverage — just adapting to the type change): +- `Tests/FunctionsTests/FunctionsClientTests.swift:2` has `import HTTPTypes`, but nothing in that file actually uses an `HTTPTypes` symbol (`Mock(... data: [.post: ...])`'s `.post` resolves to `Mocker`'s own `HTTPMethod` enum, not `HTTPTypes`) — this import is already dead code today and should simply be deleted. +- `Tests/FunctionsTests/FunctionInvokeOptionsTests.swift` genuinely uses `HTTPTypes`: `import HTTPTypes` (line 1), `options.headers[.contentType]` (an `HTTPFields` subscript, 4 call sites), and `testMethod()`'s `[FunctionInvokeOptions.Method: HTTPTypes.HTTPRequest.Method]` dictionary. Update to `import HTTPRuntime` (transitively available via `Functions`'s new dependency on it — same mechanism that made `import HTTPTypes` resolve there today without an explicit `FunctionsTests` product dependency; no `Package.swift` change needed for the test target), `options.headers["Content-Type"]` (plain dictionary subscript), and `[FunctionInvokeOptions.Method: HTTPMethod]` with `HTTPRuntime.HTTPMethod`'s cases (`.get`/`.post`/`.put`/`.patch`/`.delete`). + +## Error handling + +- Transport-level failures surface as `HTTPError.transport(underlying)` from both `FetchHandlerTransport.send` and `URLSessionTransport.stream`. `FunctionsClient` catches `.transport(let underlying)` at both call sites and re-throws/finishes with `underlying` directly — callers see the exact same error type they see today (see "Errors" above; this is test-verified, not just a design intention). +- Non-2xx responses and the relay-error header check keep their exact current semantics, just reading from `HTTPResponse`/`HTTPResponseHead` instead of `Helpers.HTTPResponse`. +- `invoke(_:options:decode:)` synthesizes an `HTTPURLResponse` from `HTTPResponseHead` + the request URL to preserve its public `(Data, HTTPURLResponse)` decode-closure signature (see "Buffered path" above).