diff --git a/Package.resolved b/Package.resolved index b6bd69d4a..3891a7f15 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b3c23ed1a3ed2644d6b543c235de921094d31296e5858943a31c994d3077d6db", + "originHash" : "68f967376d6ba9076bcf23874fbae2c06bc3df30d1d3d4cc7274c39992a4814d", "pins" : [ { "identity" : "mocker", @@ -10,15 +10,6 @@ "version" : "3.0.2" } }, - { - "identity" : "opentelemetry-swift-core", - "kind" : "remoteSourceControl", - "location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git", - "state" : { - "revision" : "06f8a460a66f813758d22f09025d85df45450a63", - "version" : "2.5.1" - } - }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", @@ -28,15 +19,6 @@ "version" : "1.3.1" } }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ff1f995e2..f358ad3f3 100644 --- a/Package.swift +++ b/Package.swift @@ -54,6 +54,29 @@ let package = Package( "Helpers", ] ), + .target( + name: "HTTPRuntime" + ), + .testTarget( + name: "HTTPRuntimeTests", + dependencies: [ + "HTTPRuntime" + ] + ), + .target( + name: "HTTPRuntimeTestHelpers", + dependencies: [ + "HTTPRuntime", + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + ] + ), + .testTarget( + name: "HTTPRuntimeTestHelpersTests", + dependencies: [ + "HTTPRuntime", + "HTTPRuntimeTestHelpers", + ] + ), .target( name: "Auth", dependencies: [ @@ -84,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( @@ -247,7 +270,9 @@ let package = Package( // Test targets migrated to Swift Testing get full Swift 6 checking, same as // production targets. Everything else stays pinned to v5 until its migration // phase lands (see SDK-435). -let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests"] +let swift6TestTargets: Set = [ + "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", "HTTPRuntimeTestHelpersTests", +] for target in package.targets { // Test targets never opted into `ExistentialAny` below, so bumping swift-tools-version 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/HTTPError.swift b/Sources/HTTPRuntime/HTTPError.swift new file mode 100644 index 000000000..5256040da --- /dev/null +++ b/Sources/HTTPRuntime/HTTPError.swift @@ -0,0 +1,43 @@ +// +// HTTPError.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// Errors surfaced by the runtime itself (transport/encoding/decoding), as +/// distinct from typed API errors decoded from a response body. +package enum HTTPError: Error, Sendable { + case invalidURL(base: URL, path: String) + case transport(any Error) + case decoding(any Error) + // case encoding(any Error) + /// A non-success status whose body did not decode to any modeled error. + case unexpectedResponse(response: HTTPResponse, underlyingError: (any Error)? = nil) +} + +/// Marker protocol for generated, typed API errors decoded from a response +/// body for a known status code. +package protocol APIError: Error, Sendable, Decodable {} + +extension HTTPResponse { + /// Validates the status code, decoding a modeled error when the status + /// matches one of the provided error types. + package func checkStatus( + errorTypes: [Int: any APIError.Type], + catchAll defaultError: any APIError.Type + ) throws { + guard !head.isSuccess else { return } + + let errorType = errorTypes[head.status] ?? defaultError + + let decodedError: any APIError + do { + decodedError = try JSONCoding.decoder.decode(errorType, from: body) + } catch { + throw HTTPError.unexpectedResponse(response: self, underlyingError: error) + } + throw decodedError + } +} diff --git a/Sources/HTTPRuntime/HTTPMethod.swift b/Sources/HTTPRuntime/HTTPMethod.swift new file mode 100644 index 000000000..fe3f8ecb0 --- /dev/null +++ b/Sources/HTTPRuntime/HTTPMethod.swift @@ -0,0 +1,17 @@ +// +// HTTPMethod.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +// Part of the internal HTTP runtime. NEVER exposed as public SDK surface. + +/// HTTP verbs supported by the generated operations. +package enum HTTPMethod: String, Sendable, Hashable { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" + case head = "HEAD" +} diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift new file mode 100644 index 000000000..0954e4e1c --- /dev/null +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -0,0 +1,122 @@ +// +// HTTPRequest.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// The body of an outgoing request. +/// +/// `.file` is the key to streaming large uploads without loading them into +/// memory: `URLSession` streams the file from disk. Multipart requests are +/// assembled by the caller via `MultipartFormData.buildToTempFile()` and +/// passed as `.file`, with the `Content-Type` header set to +/// `MultipartFormData.contentType`. +package enum HTTPBody: Sendable { + case data(Data) + case file(URL) +} + +/// A fully-resolved HTTP request: absolute URL, headers, and body. +package struct HTTPRequest: Sendable { + package var method: HTTPMethod + 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, + timeout: TimeInterval? = nil + ) { + self.method = method + self.url = url + self.headers = headers + self.body = body + self.timeout = timeout + } +} + +/// Assembles an `HTTPRequest` from a base URL, a path template already filled +/// with path parameters, repeated query items, and headers. +/// +/// Generated code drives this builder; it never constructs `URLComponents` +/// directly. Query values use repeated-key encoding (`?k=a&k=b`) to match the +/// Smithy/OpenAPI list conventions in the specs. +package struct HTTPRequestBuilder: Sendable { + private let method: HTTPMethod + private let baseURL: URL + private let path: String + private var queryItems: [URLQueryItem] = [] + private var headers: [String: String] = [:] + private var body: HTTPBody? = nil + + package init(method: HTTPMethod, baseURL: URL, path: String) { + self.method = method + self.baseURL = baseURL + self.path = path + } + + package mutating func addQuery(_ name: String, _ value: String?) { + guard let value else { return } + queryItems.append(URLQueryItem(name: name, value: value)) + } + + package mutating func addQuery(_ name: String, _ values: [String]?) { + guard let values else { return } + for value in values { + queryItems.append(URLQueryItem(name: name, value: value)) + } + } + + package mutating func setHeader(_ name: String, _ value: String?) { + guard let value else { return } + headers[canonicalKey(for: name)] = value + } + + /// Appends to an existing header value (joined with `"; "`) instead of + /// replacing it, e.g. repeated `Prefer` directives. Header names are + /// matched case-insensitively per HTTP semantics. + package mutating func addHeader(_ name: String, value: String?) { + guard let value else { return } + let key = canonicalKey(for: name) + if let existing = headers[key] { + headers[key] = "\(existing); \(value)" + } else { + headers[key] = value + } + } + + /// Returns the already-stored key matching `name` case-insensitively, if + /// any, so repeated calls with different casing merge into one header + /// instead of creating a duplicate entry. + private func canonicalKey(for name: String) -> String { + headers.keys.first { $0.caseInsensitiveCompare(name) == .orderedSame } ?? name + } + + package mutating func setBody(_ body: HTTPBody?) { + self.body = body + } + + package func build() throws -> HTTPRequest { + // Compose by string so slashes inside greedy path params ({path+}) are + // preserved. Generated code percent-encodes individual label values. + var base = baseURL.absoluteString + if base.hasSuffix("/") { base.removeLast() } + let prefixedPath = path.hasPrefix("/") ? path : "/" + path + guard var components = URLComponents(string: base + prefixedPath) else { + throw HTTPError.invalidURL(base: baseURL, path: path) + } + if !queryItems.isEmpty { + components.queryItems = queryItems + } + guard let url = components.url else { + throw HTTPError.invalidURL(base: baseURL, path: path) + } + return HTTPRequest(method: method, url: url, headers: headers, body: body) + } +} diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift new file mode 100644 index 000000000..237265061 --- /dev/null +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -0,0 +1,68 @@ +// +// HTTPResponse.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// The status line and headers of a response, without the body. +/// +/// Used on its own for streaming responses (where the body is delivered +/// separately as an `AsyncSequence`) and embedded in ``HTTPResponse`` for +/// buffered responses. +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 + } + + package func header(_ name: String) -> String? { + // HTTP header names are case-insensitive. + if let exact = headers[name] { return exact } + let lowered = name.lowercased() + 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) } +} + +/// A fully-buffered response. +package struct HTTPResponse: Sendable { + package let head: HTTPResponseHead + package let body: Data + + package init(head: HTTPResponseHead, body: Data) { + self.head = head + self.body = body + } +} + +/// A streaming response: the head arrives first, the body is an async sequence +/// of `Data` chunks (used for large downloads and event streams). +package struct HTTPResponseStream: Sendable { + package let head: HTTPResponseHead + package let body: AsyncThrowingStream + + package init(head: HTTPResponseHead, body: AsyncThrowingStream) { + self.head = head + 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 new file mode 100644 index 000000000..c4e2f7225 --- /dev/null +++ b/Sources/HTTPRuntime/HTTPTransport.swift @@ -0,0 +1,26 @@ +// +// HTTPTransport.swift +// HTTPRuntime +// +// 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 -> 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 -> HTTPResponseStream +} + +extension HTTPTransport { + package func send(_ request: HTTPRequest) async throws -> HTTPResponse { + try await send(request, uploadProgress: nil) + } +} diff --git a/Sources/HTTPRuntime/JSONCoding.swift b/Sources/HTTPRuntime/JSONCoding.swift new file mode 100644 index 000000000..b7b25262b --- /dev/null +++ b/Sources/HTTPRuntime/JSONCoding.swift @@ -0,0 +1,53 @@ +// +// JSONCoding.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// Shared JSON coders used by generated code. Dates are ISO-8601 with +/// fractional seconds, matching the mock server and the spec timestamps. +package enum JSONCoding { + package static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, enc in + var container = enc.singleValueContainer() + try container.encode(iso8601.string(from: date)) + } + return encoder + }() + + package static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { dec in + let container = try dec.singleValueContainer() + let string = try container.decode(String.self) + guard let date = iso8601.date(from: string) ?? iso8601NoFraction.date(from: string) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO-8601 date: \(string)" + ) + } + return date + } + return decoder + }() + + /// ISO-8601 string with fractional seconds, for `@httpQuery` timestamp params. + package static func iso8601String(_ date: Date) -> String { + iso8601.string(from: date) + } + + nonisolated(unsafe) private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + nonisolated(unsafe) private static let iso8601NoFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/Sources/HTTPRuntime/JSONValue.swift b/Sources/HTTPRuntime/JSONValue.swift new file mode 100644 index 000000000..abd967043 --- /dev/null +++ b/Sources/HTTPRuntime/JSONValue.swift @@ -0,0 +1,51 @@ +// +// JSONValue.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// A free-form JSON value. +package enum JSONValue: Codable, Sendable, Hashable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .object(let value): try container.encode(value) + } + } +} diff --git a/Sources/HTTPRuntime/MultipartFormData.swift b/Sources/HTTPRuntime/MultipartFormData.swift new file mode 100644 index 000000000..9c65d7899 --- /dev/null +++ b/Sources/HTTPRuntime/MultipartFormData.swift @@ -0,0 +1,272 @@ +// +// MultipartFormData.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +/// Builds a `multipart/form-data` body by streaming its parts onto a temporary +/// file, so large file parts never load fully into memory. The resulting file +/// is then uploaded with `URLSession.upload(for:fromFile:)`. +/// +/// This is the runtime's answer to "streaming multipart upload of large files +/// without loading into memory": file parts are copied chunk-by-chunk to the +/// staging file. +package struct MultipartFormData: Sendable { + let boundary: String + private var parts: [Part] = [] + + package var contentType: String { "multipart/form-data; boundary=\(boundary)" } + + enum Part { + case text(name: String, value: String) + case data(name: String, data: Data, fileName: String?, mimeType: String?) + case file(name: String, fileURL: URL, fileName: String?, mimeType: String?) + } + + package init(boundary: String = "----sb-\(UUID().uuidString)") { + self.boundary = boundary + } + + /// Add a text field to the multipart payload + package func addText(name: String, value: String) -> MultipartFormData { + var builder = self + builder.parts.append(.text(name: name, value: value)) + return builder + } + + /// Add an optional text field (only adds if value is non-nil) + package func addOptionalText(name: String, value: String?) -> MultipartFormData { + if let value = value { + return addText(name: name, value: value) + } + return self + } + + /// Add a data field to the multipart payload. + package func addData( + name: String, + data: Data, + fileName: String? = nil, + mimeType: String? = nil + ) -> MultipartFormData { + var builder = self + builder.parts.append(.data(name: name, data: data, fileName: fileName, mimeType: mimeType)) + return builder + } + + /// Add a file field to the multipart payload (loads entire file into memory) + package func addFile( + name: String, + fileURL: URL, + fileName: String? = nil, + mimeType: String? = nil + ) -> MultipartFormData { + var builder = self + builder.parts.append( + .file(name: name, fileURL: fileURL, fileName: fileName, mimeType: mimeType)) + return builder + } + + /// Build the multipart payload in memory + /// - Note: Only suitable for small payloads. Use `buildToTempFile()` for large files. + /// - Returns: Complete multipart body data + package func buildInMemory() throws -> Data { + guard !parts.isEmpty else { return Data() } + + var body = Data() + + for part in parts { + switch part { + case .text(let name, let value): + body.append(textPart(name: name, value: value)) + case .data(let name, let data, let fileName, let mimeType): + body.append(dataPart(name: name, data: data, fileName: fileName, mimeType: mimeType)) + case .file(let name, let fileURL, let fileName, let mimeType): + body.append( + try filePart(name: name, fileURL: fileURL, fileName: fileName, mimeType: mimeType) + ) + } + } + + body.append(closingBoundary()) + return body + } + + /// Build the multipart payload to a temporary file + /// - Note: Streams file contents to avoid memory pressure on large files + /// - Returns: URL of temporary file containing multipart body + package func buildToTempFile() throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + + // Create temp file + guard FileManager.default.createFile(atPath: tempFile.path, contents: nil) else { + throw MultipartFormatDataError.createTempFileFailed + } + + var didReturnTempFile = false + defer { + if !didReturnTempFile { + try? FileManager.default.removeItem(at: tempFile) + } + } + + guard let handle = FileHandle(forWritingAtPath: tempFile.path) else { + throw MultipartFormatDataError.openTempFileFailed + } + + defer { try? handle.close() } + + guard !parts.isEmpty else { + didReturnTempFile = true + return tempFile + } + + // Write parts + for part in parts { + switch part { + case .text(let name, let value): + let data = textPart(name: name, value: value) + try handle.write(contentsOf: data) + + case .data(let name, let data, let fileName, let mimeType): + try handle.write(contentsOf: partHeader(name: name, fileName: fileName, mimeType: mimeType)) + try handle.write(contentsOf: data) + try handle.write(contentsOf: Data("\r\n".utf8)) + + case .file(let name, let fileURL, let fileName, let mimeType): + // Write file part header + let header = partHeader( + name: name, + fileName: fileName ?? fileURL.lastPathComponent, + mimeType: mimeType + ) + try handle.write(contentsOf: header) + + // Stream file contents in chunks + try streamFile(from: fileURL, to: handle) + + // Write trailing newline + try handle.write(contentsOf: Data("\r\n".utf8)) + } + } + + // Write closing boundary + try handle.write(contentsOf: closingBoundary()) + + didReturnTempFile = true + return tempFile + } + + // MARK: - Private Helpers + + private func textPart(name: String, value: String) -> Data { + var data = Data() + data.append(Data("--\(boundary)\r\n".utf8)) + data.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n".utf8)) + data.append(Data("\r\n".utf8)) + data.append(Data("\(value)\r\n".utf8)) + return data + } + + private func dataPart( + name: String, + data: Data, + fileName: String?, + mimeType: String? + ) -> Data { + var partData = Data() + partData.append(partHeader(name: name, fileName: fileName, mimeType: mimeType)) + partData.append(data) + partData.append(Data("\r\n".utf8)) + return partData + } + + private func filePart( + name: String, + fileURL: URL, + fileName: String?, + mimeType: String? + ) throws -> Data { + var data = Data() + + data.append( + partHeader(name: name, fileName: fileName ?? fileURL.lastPathComponent, mimeType: mimeType) + ) + + let fileData = try Data(contentsOf: fileURL) + data.append(fileData) + + data.append(Data("\r\n".utf8)) + + return data + } + + private func partHeader(name: String, fileName: String?, mimeType: String?) -> Data { + var header = Data() + header.append(Data("--\(boundary)\r\n".utf8)) + var disposition = "Content-Disposition: form-data; name=\"\(name)\"" + if let fileName { + disposition.append("; filename=\"\(fileName)\"") + } + header.append(Data("\(disposition)\r\n".utf8)) + + if let mimeType = mimeType { + header.append(Data("Content-Type: \(mimeType)\r\n".utf8)) + } + + header.append(Data("\r\n".utf8)) + return header + } + + private func closingBoundary() -> Data { + return Data("--\(boundary)--\r\n".utf8) + } + + private func streamFile(from url: URL, to handle: FileHandle) throws { + guard let input = InputStream(url: url) else { + throw MultipartFormatDataError.openInputStreamFailed + } + + input.open() + defer { input.close() } + + let bufferSize = 64 * 1024 // 64KB chunks + var buffer = [UInt8](repeating: 0, count: bufferSize) + + while input.hasBytesAvailable { + let bytesRead = input.read(&buffer, maxLength: bufferSize) + if bytesRead > 0 { + let data = Data(bytes: buffer, count: bytesRead) + try handle.write(contentsOf: data) + } else if bytesRead < 0 { + throw MultipartFormatDataError.readInputStreamFailed(underlying: input.streamError) + } + } + } +} + +// MARK: - Errors + +/// Errors that can occur during multipart building operations. +package enum MultipartFormatDataError: LocalizedError { + case createTempFileFailed + case openTempFileFailed + case openInputStreamFailed + case readInputStreamFailed(underlying: (any Error)?) + + package var errorDescription: String? { + switch self { + case .createTempFileFailed: + return "Failed to create temp file" + case .openTempFileFailed: + return "Failed to create temp file" + case .openInputStreamFailed: + return "Failed to open file for reading" + case .readInputStreamFailed: + return "Error reading file" + } + } +} diff --git a/Sources/HTTPRuntime/PathEncoding.swift b/Sources/HTTPRuntime/PathEncoding.swift new file mode 100644 index 000000000..4e6d98b0d --- /dev/null +++ b/Sources/HTTPRuntime/PathEncoding.swift @@ -0,0 +1,26 @@ +// +// PathEncoding.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +import Foundation + +/// Percent-encoding for URL path parameters. +package enum PathEncoding { + private static let segmentAllowed: CharacterSet = { + var set = CharacterSet.urlPathAllowed + set.remove("/") // a single path segment must escape slashes + return set + }() + + /// Encodes a single path segment (escapes `/`). + package static func segment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? value + } + + /// Encodes a greedy label (`{path+}`) that may legitimately contain `/`. + package static func greedy(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value + } +} diff --git a/Sources/HTTPRuntime/TransferProgress.swift b/Sources/HTTPRuntime/TransferProgress.swift new file mode 100644 index 000000000..be1781375 --- /dev/null +++ b/Sources/HTTPRuntime/TransferProgress.swift @@ -0,0 +1,27 @@ +// +// TransferProgress.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +/// Progress of an upload or download. +package struct TransferProgress: Sendable, Hashable { + /// Bytes transferred so far. + package let completed: Int64 + /// Total expected bytes, or `nil` when the length is unknown. + package let total: Int64? + + package init(completed: Int64, total: Int64?) { + self.completed = completed + self.total = total + } + + /// Fraction in `0...1`, or `nil` when the total is unknown. + package var fraction: Double? { + guard let total, total > 0 else { return nil } + return Double(completed) / Double(total) + } +} + +/// A `@Sendable` progress callback invoked as bytes move. +package typealias ProgressHandler = @Sendable (TransferProgress) -> Void diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift new file mode 100644 index 000000000..e6db1e07b --- /dev/null +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -0,0 +1,158 @@ +// +// URLSessionTransport.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// +package import Foundation + +#if canImport(FoundationNetworking) + package import FoundationNetworking +#endif + +/// The default, zero-dependency `HTTPTransport` backed by `URLSession`. +/// +/// Design notes (and a real URLSession constraint worth recording): +/// - Buffered and streaming requests use the modern async `URLSession` APIs +/// (`upload(for:fromFile:)`, `bytes(for:)`), which keep the code fully +/// async/await + `AsyncSequence` and Sendable-clean. +/// - Uploads stream from a file on disk (`.file`, including caller-assembled +/// multipart bodies), so large bodies never fully buffer in memory. Progress +/// is reported via a per-task delegate. +/// - Background sessions are exposed via `init(configuration:)`, BUT the async +/// convenience APIs are not supported on a background `URLSessionConfiguration` +/// — background transfers must use delegate-based `downloadTask`/`uploadTask` +/// that complete out-of-process. That path is documented as a known limitation +/// rather than faked here. +package struct URLSessionTransport: HTTPTransport { + private let session: URLSession + + package init( + configuration: URLSessionConfiguration = .default + ) { + self.session = URLSession(configuration: configuration) + } + + package init( + session: URLSession + ) { + self.session = session + } + + package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) + async throws -> HTTPResponse + { + let urlRequest = Self.makeURLRequest(request) + let delegate = uploadProgress.map { ProgressDelegate(onProgress: $0) } + + let (data, response) = + switch request.body { + case nil: + try await session.data(for: urlRequest, delegate: delegate) + case .data(let payload): + try await session.upload( + for: urlRequest, from: payload, delegate: delegate) + case .file(let fileURL): + try await session.upload( + for: urlRequest, fromFile: fileURL, delegate: delegate) + } + return HTTPResponse(head: Self.makeHead(response), body: data) + } + + #if canImport(FoundationNetworking) + // 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 -> HTTPResponseStream { + let urlRequest = Self.makeURLRequest(request) + let (data, response) = try await session.data(for: urlRequest) + + let body = AsyncThrowingStream { continuation in + continuation.yield(data) + continuation.finish() + } + return HTTPResponseStream(head: Self.makeHead(response), body: body) + } + #else + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { + let urlRequest = Self.makeURLRequest(request) + let (bytes, response) = try await session.bytes(for: urlRequest) + + let body = AsyncThrowingStream { continuation in + let task = Task { + do { + var buffer = [UInt8]() + buffer.reserveCapacity(16 * 1024) + for try await byte in bytes { + buffer.append(byte) + // Flush on newline (prompt SSE frame delivery) or when a + // chunk fills up (bounded memory for large downloads). + if byte == 0x0A || buffer.count >= 16 * 1024 { + continuation.yield(Data(buffer)) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { continuation.yield(Data(buffer)) } + continuation.finish() + } catch { + continuation.finish(throwing: HTTPError.transport(error)) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return HTTPResponseStream(head: Self.makeHead(response), body: body) + } + #endif + + // MARK: - Helpers + + package static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { + var urlRequest = URLRequest(url: request.url) + 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 + } + if let timeout = request.timeout { + urlRequest.timeoutInterval = timeout + } + return urlRequest + } + + package static func makeHead(_ response: URLResponse) -> HTTPResponseHead { + guard let http = response as? HTTPURLResponse else { + return HTTPResponseHead(status: 0, headers: [:]) + } + var headers: [String: String] = [:] + for (key, value) in http.allHeaderFields { + if let key = key as? String, let value = value as? String { + headers[key] = value + } + } + var head = HTTPResponseHead(status: http.statusCode, headers: headers) + head._underlyingHTTPResponse = http + return head + } +} + +/// Per-task delegate that forwards upload progress. +private final class ProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { + private let onProgress: ProgressHandler + + init(onProgress: @escaping ProgressHandler) { + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + let total = totalBytesExpectedToSend > 0 ? totalBytesExpectedToSend : nil + onProgress(TransferProgress(completed: totalBytesSent, total: total)) + } +} diff --git a/Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift b/Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift new file mode 100644 index 000000000..6e4ad3a89 --- /dev/null +++ b/Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift @@ -0,0 +1,33 @@ +// +// AssertHTTPRequests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import HTTPRuntime +@preconcurrency import InlineSnapshotTesting + +/// Runs `operation`, then asserts an inline curl snapshot of every request +/// `operation` made against the ambient `HTTPTransportStub.current` — i.e. +/// this must run inside a `.http(stubs:)` scope. Multiple requests made +/// during `operation` render as multiple curl commands joined by a blank +/// line, in call order. +package func assertHTTPRequests( + fileID: StaticString = #fileID, filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, column: UInt = #column, + _ operation: () async throws -> R, + matches expected: (() -> String)? = nil +) async throws -> R { + let transport = HTTPTransportStub.current + let startIndex = await transport.requestCount + let result = try await operation() + let requests = await transport.requests(since: startIndex) + let rendered = requests.map(curlCommand(for:)).joined(separator: "\n\n") + assertInlineSnapshot( + of: rendered, as: .lines, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor(trailingClosureOffset: 1), + matches: expected, + fileID: fileID, file: filePath, function: function, line: line, column: column) + return result +} diff --git a/Sources/HTTPRuntimeTestHelpers/CurlCommand.swift b/Sources/HTTPRuntimeTestHelpers/CurlCommand.swift new file mode 100644 index 000000000..c8ebd5bfb --- /dev/null +++ b/Sources/HTTPRuntimeTestHelpers/CurlCommand.swift @@ -0,0 +1,55 @@ +// +// CurlCommand.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +package import HTTPRuntime + +/// Renders an `HTTPRequest` as a curl command — method, sorted headers, +/// escaped body, sorted query items. Mirrors the conventions of +/// `Sources/TestHelpers/URLRequestSnapshot.swift`'s `._curl` strategy for +/// `URLRequest`, implemented independently against `HTTPRequest` so this +/// target has no dependency on `TestHelpers`. `.file` request bodies aren't +/// rendered (no `--data` line) — out of scope for this helper's JSON-body +/// use case. +package func curlCommand(for request: HTTPRequest) -> String { + var components = ["curl"] + + switch request.method { + case .get: break + case .head: components.append("--head") + default: components.append("--request \(request.method.rawValue)") + } + + for field in request.headers.keys.sorted() where field != "Cookie" { + let escapedValue = request.headers[field]!.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--header \"\(field): \(escapedValue)\"") + } + + if case .data(let data) = request.body, let httpBody = String(data: data, encoding: .utf8) { + var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") + escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--data \"\(escapedBody)\"") + } + + if let cookie = request.headers["Cookie"] { + let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--cookie \"\(escapedValue)\"") + } + + components.append("\"\(sortedQueryURL(request.url).absoluteString)\"") + + return components.joined(separator: " \\\n\t") +} + +private func sortedQueryURL(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems + else { + return url + } + components.queryItems = queryItems.sorted { $0.name < $1.name } + return components.url ?? url +} diff --git a/Sources/HTTPRuntimeTestHelpers/HTTPStub.swift b/Sources/HTTPRuntimeTestHelpers/HTTPStub.swift new file mode 100644 index 000000000..6181be933 --- /dev/null +++ b/Sources/HTTPRuntimeTestHelpers/HTTPStub.swift @@ -0,0 +1,72 @@ +// +// HTTPStub.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +package import HTTPRuntime + +/// A canned response for one request, matched by HTTP method + full URL +/// (including query), consumed in the order it appears in `.http(stubs:)`'s +/// array. Only ever describes the *response* — see `assertHTTPRequests` to +/// assert the shape of the outgoing request. +package struct HTTPStub: Sendable { + package let method: HTTPMethod + package let url: String + package let status: Int + package let headers: [String: String] + package let body: @Sendable () -> HTTPStubBody + + private init( + method: HTTPMethod, url: String, status: Int, headers: [String: String], + body: @escaping @Sendable () -> HTTPStubBody + ) { + self.method = method + self.url = url + self.status = status + self.headers = headers + self.body = body + } + + package static func get( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .get, url: url, status: status, headers: headers, body: body) + } + + package static func post( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .post, url: url, status: status, headers: headers, body: body) + } + + package static func put( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .put, url: url, status: status, headers: headers, body: body) + } + + package static func patch( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .patch, url: url, status: status, headers: headers, body: body) + } + + package static func delete( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .delete, url: url, status: status, headers: headers, body: body) + } + + package static func head( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .head, url: url, status: status, headers: headers, body: body) + } +} diff --git a/Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift b/Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift new file mode 100644 index 000000000..3d219b1fe --- /dev/null +++ b/Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift @@ -0,0 +1,16 @@ +// +// HTTPStubBody.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +package import Foundation + +/// The canned response body for an ``HTTPStub``. +package enum HTTPStubBody: Sendable { + case empty + case string(String) + case data(Data) + /// Chunks delivered over time — for stubbing `HTTPTransport.stream()`. + case stream(AsyncStream) +} diff --git a/Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift b/Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift new file mode 100644 index 000000000..04d3295c2 --- /dev/null +++ b/Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift @@ -0,0 +1,175 @@ +// +// HTTPTransportStub.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +package import HTTPRuntime +package import Testing + +/// Thrown into `HTTPError.transport` on a stub mismatch — the actual test +/// failure is the `Issue.record` call alongside it; this just gives the code +/// under test a real error to handle if it inspects the failure. +package struct HTTPStubMismatch: Error, CustomStringConvertible { + package let description: String +} + +/// The `HTTPTransport` backing `.http(stubs:)` — an ordered, consume-once +/// stub queue. Bound to the current task tree via `HTTPStubTrait` (below). +package actor HTTPTransportStub: HTTPTransport { + @TaskLocal fileprivate static var _current: HTTPTransportStub? + + /// The stub transport bound by the enclosing `.http(stubs:)` trait scope. + /// Outside such a scope, accessing this records an issue and returns an + /// empty-queue instance — any request against it fails through the normal + /// "no stubs remaining" path below rather than crashing. + package static var current: HTTPTransportStub { + guard let value = _current else { + Issue.record("HTTPTransportStub.current accessed outside a .http trait scope") + return HTTPTransportStub(stubs: []) + } + return value + } + + private var pending: [HTTPStub] + private var consumedRequests: [HTTPRequest] = [] + + package init(stubs: [HTTPStub]) { + pending = stubs + } + + private func nextMatchingStub(for request: HTTPRequest) throws(HTTPError) -> HTTPStub { + consumedRequests.append(request) + guard !pending.isEmpty else { + let message = + "Unexpected request \(request.method.rawValue) \(request.url.absoluteString) — no stubs remaining" + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + let stub = pending.removeFirst() + guard stub.method == request.method, stub.url == request.url.absoluteString else { + let message = """ + Request mismatch. + Expected: \(stub.method.rawValue) \(stub.url) + Actual: \(request.method.rawValue) \(request.url.absoluteString) + """ + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + return stub + } + + package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) + async throws(HTTPError) + -> HTTPResponse + { + let stub = try nextMatchingStub(for: request) + let bodyData: Data + switch stub.body() { + case .empty: + bodyData = Data() + case .string(let value): + bodyData = Data(value.utf8) + case .data(let value): + bodyData = value + case .stream(let stream): + var collected = Data() + for await chunk in stream { collected.append(chunk) } + bodyData = collected + } + return HTTPResponse( + head: HTTPResponseHead(status: stub.status, headers: stub.headers), body: bodyData) + } + + package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + let stub = try nextMatchingStub(for: request) + let responseBody: AsyncThrowingStream + switch stub.body() { + case .empty: + responseBody = AsyncThrowingStream { $0.finish() } + case .string(let value): + responseBody = AsyncThrowingStream { continuation in + continuation.yield(Data(value.utf8)) + continuation.finish() + } + case .data(let value): + responseBody = AsyncThrowingStream { continuation in + continuation.yield(value) + continuation.finish() + } + case .stream(let stream): + responseBody = AsyncThrowingStream { continuation in + let task = Task { + for await chunk in stream { continuation.yield(chunk) } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + return HTTPResponseStream( + head: HTTPResponseHead(status: stub.status, headers: stub.headers), body: responseBody) + } + + /// Records an issue for every stub that was never consumed. Called by + /// `HTTPStubTrait` at scope exit. + package func assertAllConsumed() { + for stub in pending { + Issue.record("Stub for \(stub.method.rawValue) \(stub.url) was never consumed") + } + } + + /// Count of requests recorded so far — `assertHTTPRequests` snapshots this + /// before running its operation, then diffs against it after. + package var requestCount: Int { consumedRequests.count } + + /// Requests recorded from `index` onward. + package func requests(since index: Int) -> [HTTPRequest] { Array(consumedRequests[index...]) } + + /// Hands off stubs not yet consumed to a nested `HTTPStubTrait` scope + /// (below), clearing this instance's own queue in the process — the nested + /// scope's transport takes over responsibility for them, so this instance + /// won't also flag them as leftover when its own scope exits. + fileprivate func takeRemainingStubs() -> [HTTPStub] { + defer { pending = [] } + return pending + } +} + +/// Declares canned responses for `HTTPTransport`-issued requests made during +/// a test. Usable at `@Test` or `@Suite` level; a `@Test`-level trait appends +/// its stubs to whatever an enclosing `@Suite`-level trait already queued, +/// preserving order. +package struct HTTPStubTrait: TestTrait, SuiteTrait, TestScoping { + package let isRecursive = true + + fileprivate let stubs: [HTTPStub] + + package func provideScope( + for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void + ) async throws { + let outerStubs = await HTTPTransportStub._current?.takeRemainingStubs() ?? [] + let transport = HTTPTransportStub(stubs: outerStubs + stubs) + try await HTTPTransportStub.$_current.withValue(transport) { + try await function() + await transport.assertAllConsumed() + } + } +} + +extension Trait where Self == HTTPStubTrait { + /// `@Test(.http(stubs: [.get("https://example.com/x") { .string("...") }]))` + /// + /// Declared here (rather than relying on the free `http(stubs:)` function + /// below) because leading-dot trait syntax only resolves through a static + /// member on `Trait` — a free function isn't found by that lookup. + package static func http(stubs: [HTTPStub]) -> Self { + HTTPStubTrait(stubs: stubs) + } +} + +/// Constructs an `HTTPStubTrait` directly, e.g. to drive `provideScope(...)` +/// by hand in a test body rather than via `@Test(.http(stubs:))`. +package func http(stubs: [HTTPStub]) -> HTTPStubTrait { + HTTPStubTrait(stubs: stubs) +} diff --git a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme index 329c48f39..4b4a64efb 100644 --- a/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme +++ b/Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme @@ -177,6 +177,28 @@ ReferencedContainer = "container:"> + + + + + + + + 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/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift b/Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift new file mode 100644 index 000000000..be6056c69 --- /dev/null +++ b/Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift @@ -0,0 +1,73 @@ +// +// AssertHTTPRequestsTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct AssertHTTPRequestsTests { + @Test( + .http(stubs: [ + .get("https://example.com/a") { .empty }, + .get("https://example.com/b") { .empty }, + .get("https://example.com/c") { .empty }, + ])) + func onlyCapturesRequestsMadeDuringItsOwnOperation() async throws { + // Fires one request *before* any assertHTTPRequests call — must not leak + // into the slice captured below. + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/b")!), uploadProgress: nil) + } matches: { + #""" + curl \ + "https://example.com/b" + """# + } + + // A second call must only see requests made after the first one returned. + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/c")!), uploadProgress: nil) + } matches: { + #""" + curl \ + "https://example.com/c" + """# + } + } + + @Test( + .http(stubs: [ + .get("https://example.com/first") { .empty }, + .post("https://example.com/second") { .empty }, + ])) + func rendersMultipleRequestsJoinedByBlankLine() async throws { + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/first")!), + uploadProgress: nil) + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .post, url: URL(string: "https://example.com/second")!), + uploadProgress: nil) + } matches: { + #""" + curl \ + "https://example.com/first" + + curl \ + --request POST \ + "https://example.com/second" + """# + } + } +} diff --git a/Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift b/Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift new file mode 100644 index 000000000..59696d254 --- /dev/null +++ b/Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift @@ -0,0 +1,56 @@ +// +// CurlCommandTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct CurlCommandTests { + @Test + func rendersGetWithSortedHeadersAndQuery() { + let request = HTTPRequest( + method: .get, + url: URL(string: "https://example.com/x?b=2&a=1")!, + headers: ["Content-Type": "application/json", "Accept": "application/json"]) + #expect( + curlCommand(for: request) == """ + curl \\ + \t--header "Accept: application/json" \\ + \t--header "Content-Type: application/json" \\ + \t"https://example.com/x?a=1&b=2" + """) + } + + @Test + func rendersPostWithEscapedBody() { + let request = HTTPRequest( + method: .post, + url: URL(string: "https://example.com/x")!, + headers: [:], + body: .data(Data(#"{"a":1}"#.utf8))) + #expect( + curlCommand(for: request) == #""" + curl \ + --request POST \ + --data "{\"a\":1}" \ + "https://example.com/x" + """#) + } + + @Test + func rendersHead() { + let request = HTTPRequest(method: .head, url: URL(string: "https://example.com/x")!) + #expect( + curlCommand(for: request) == """ + curl \\ + \t--head \\ + \t"https://example.com/x" + """) + } +} diff --git a/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift b/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift new file mode 100644 index 000000000..54a8d85c0 --- /dev/null +++ b/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift @@ -0,0 +1,50 @@ +// +// HTTPStubTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import HTTPRuntime +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct HTTPStubTests { + @Test + func getBuildsExpectedStub() { + let stub = HTTPStub.get("https://example.com/x", status: 201, headers: ["X-Test": "1"]) { + .string("hello") + } + #expect(stub.method == .get) + #expect(stub.url == "https://example.com/x") + #expect(stub.status == 201) + #expect(stub.headers == ["X-Test": "1"]) + guard case .string(let value) = stub.body() else { + Issue.record("expected .string body") + return + } + #expect(value == "hello") + } + + @Test + func defaultsToStatus200AndEmptyBody() { + let stub = HTTPStub.post("https://example.com/y") + #expect(stub.status == 200) + #expect(stub.headers.isEmpty) + guard case .empty = stub.body() else { + Issue.record("expected .empty body") + return + } + } + + @Test + func everyVerbFactoryProducesItsMethod() { + #expect(HTTPStub.get("https://example.com").method == .get) + #expect(HTTPStub.post("https://example.com").method == .post) + #expect(HTTPStub.put("https://example.com").method == .put) + #expect(HTTPStub.patch("https://example.com").method == .patch) + #expect(HTTPStub.delete("https://example.com").method == .delete) + #expect(HTTPStub.head("https://example.com").method == .head) + } +} diff --git a/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift b/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift new file mode 100644 index 000000000..9249e3fb4 --- /dev/null +++ b/Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift @@ -0,0 +1,49 @@ +// +// HTTPStubTraitTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct HTTPStubTraitTests { + @Test(.http(stubs: [.get("https://example.com/x", status: 200) { .string("ok") }])) + func bindsCurrentForTestBody() async throws { + let response = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + #expect(response.body == Data("ok".utf8)) + } + + @Test + func leftoverStubRecordsIssueAtScopeExit() async throws { + let trait = http(stubs: [.get("https://example.com/never-called") { .empty }]) + await withKnownIssue { + try await trait.provideScope(for: Test.current!, testCase: Test.Case.current) { + // Deliberately consume nothing. + } + } + } + + @Test + func suiteAndTestStubsMergeInOrder() async throws { + let suiteLevelTrait = http(stubs: [.get("https://example.com/first") { .string("1") }]) + let testLevelTrait = http(stubs: [.get("https://example.com/second") { .string("2") }]) + try await suiteLevelTrait.provideScope(for: Test.current!, testCase: Test.Case.current) { + try await testLevelTrait.provideScope(for: Test.current!, testCase: Test.Case.current) { + let first = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/first")!), + uploadProgress: nil) + let second = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/second")!), + uploadProgress: nil) + #expect(first.body == Data("1".utf8)) + #expect(second.body == Data("2".utf8)) + } + } + } +} diff --git a/Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift b/Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift new file mode 100644 index 000000000..466b640ae --- /dev/null +++ b/Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift @@ -0,0 +1,91 @@ +// +// HTTPTransportStubTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct HTTPTransportStubTests { + @Test + func matchesAndReturnsStubbedResponse() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a", status: 201, headers: ["X": "1"]) { .string("hi") } + ]) + let response = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + #expect(response.head.status == 201) + #expect(response.head.headers == ["X": "1"]) + #expect(response.body == Data("hi".utf8)) + } + + @Test + func consumesStubsInOrder() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a") { .string("first") }, + .get("https://example.com/b") { .string("second") }, + ]) + let first = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + let second = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/b")!), uploadProgress: nil) + #expect(first.body == Data("first".utf8)) + #expect(second.body == Data("second".utf8)) + } + + @Test + func mismatchRecordsIssueAndThrows() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/expected") { .empty } + ]) + await withKnownIssue { + _ = try await transport.send( + HTTPRequest(method: .post, url: URL(string: "https://example.com/actual")!), + uploadProgress: nil) + } + } + + @Test + func exhaustedQueueRecordsIssueAndThrows() async throws { + let transport = HTTPTransportStub(stubs: []) + await withKnownIssue { + _ = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + } + } + + @Test + func assertAllConsumedRecordsIssueForLeftoverStubs() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/never-called") { .empty } + ]) + await withKnownIssue { + await transport.assertAllConsumed() + } + } + + @Test + func currentOutsideScopeRecordsIssueAndReturnsUsableTransport() async throws { + await withKnownIssue { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + } + } + + @Test + func streamYieldsStubbedChunks() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a") { .data(Data("chunk".utf8)) } + ]) + let responseStream = try await transport.stream( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!)) + var collected = Data() + for try await chunk in responseStream.body { collected.append(chunk) } + #expect(collected == Data("chunk".utf8)) + } +} diff --git a/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift b/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift new file mode 100644 index 000000000..a5bfa6ce4 --- /dev/null +++ b/Tests/HTTPRuntimeTests/HTTPRuntimeTests.swift @@ -0,0 +1,121 @@ +// +// HTTPRuntimeTests.swift +// HTTPRuntime +// +// Created by Guilherme Souza on 08/07/26. +// + +import Foundation +import Testing + +@testable import HTTPRuntime + +@Suite +struct HTTPRuntimeTests { + + @Test + func multipartAssemblesToFileWithoutBufferingSource() throws { + let sourceURL = FileManager.default.temporaryDirectory + .appendingPathComponent("src-\(UUID().uuidString).bin") + let payload = Data((0..<200_000).map { UInt8($0 % 256) }) + try payload.write(to: sourceURL) + defer { try? FileManager.default.removeItem(at: sourceURL) } + + let form = MultipartFormData(boundary: "TESTBOUNDARY") + .addText(name: "meta", value: #"{"k":"v"}"#) + .addFile( + name: "file", fileURL: sourceURL, fileName: "big.bin", mimeType: "application/octet-stream") + + let bodyURL = try form.buildToTempFile() + defer { try? FileManager.default.removeItem(at: bodyURL) } + let body = try Data(contentsOf: bodyURL) + + #expect(form.contentType == "multipart/form-data; boundary=TESTBOUNDARY") + let text = String(decoding: body.prefix(400), as: UTF8.self) + #expect(text.contains("--TESTBOUNDARY")) + #expect(text.contains(#"Content-Disposition: form-data; name="meta""#)) + #expect(text.contains(#"name="file"; filename="big.bin""#)) + #expect(body.count > payload.count) + } + + @Test + func addHeaderAppendsToExistingValue() throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: URL(string: "https://example.com")!, path: "/x") + builder.addHeader("Prefer", value: "returning=minimal") + builder.addHeader("Prefer", value: "count=exact") + let request = try builder.build() + #expect(request.headers["Prefer"] == "returning=minimal; count=exact") + } + + @Test + func addHeaderSetsWhenAbsent() throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: URL(string: "https://example.com")!, path: "/x") + builder.addHeader("Prefer", value: "returning=minimal") + let request = try builder.build() + #expect(request.headers["Prefer"] == "returning=minimal") + } + + @Test + func addHeaderMergesCaseInsensitively() throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: URL(string: "https://example.com")!, path: "/x") + builder.addHeader("Prefer", value: "returning=minimal") + builder.addHeader("prefer", value: "count=exact") + let request = try builder.build() + #expect(request.headers.count == 1) + #expect(request.headers["Prefer"] == "returning=minimal; count=exact") + } + + @Test + func setHeaderReplacesCaseInsensitively() throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: URL(string: "https://example.com")!, path: "/x") + builder.setHeader("Content-Type", "text/plain") + builder.setHeader("content-type", "application/json") + let request = try builder.build() + #expect(request.headers.count == 1) + #expect(request.headers["Content-Type"] == "application/json") + } + + @Test + func addHeaderIgnoresNilValue() throws { + var builder = HTTPRequestBuilder( + method: .get, baseURL: URL(string: "https://example.com")!, path: "/x") + builder.addHeader("Prefer", value: "returning=minimal") + builder.addHeader("Prefer", value: nil) + let request = try builder.build() + #expect(request.headers["Prefer"] == "returning=minimal") + } + + @Test + func pathEncoding() { + #expect(PathEncoding.segment("a/b c") == "a%2Fb%20c") + #expect(PathEncoding.greedy("a/b/c.txt") == "a/b/c.txt") + #expect(PathEncoding.greedy("a/b c.txt") == "a/b%20c.txt") + } + + @Test + func jsonValueRoundTrip() throws { + let value = JSONValue.object([ + "s": .string("x"), + "n": .number(3.5), + "b": .bool(true), + "arr": .array([.number(1), .null]), + ]) + let data = try JSONCoding.encoder.encode(value) + let decoded = try JSONCoding.decoder.decode(JSONValue.self, from: data) + #expect(decoded == value) + } + + @Test + func iso8601DateCoding() throws { + struct Holder: Codable, Equatable { let at: Date } + let json = #"{"at":"2026-07-06T12:34:56.789Z"}"# + let decoded = try JSONCoding.decoder.decode(Holder.self, from: Data(json.utf8)) + let reencoded = try JSONCoding.encoder.encode(decoded) + let round = try JSONCoding.decoder.decode(Holder.self, from: reencoded) + #expect(decoded == round) + } +} 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/dictionary.txt b/dictionary.txt index b1cf6f7a1..30c5a20b6 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -177,6 +177,22 @@ xcodeproj xctest XVCJ +# Terms from HTTPRuntime (Sources/HTTPRuntime) and its tests. +corelibs +reencoded +subdata +Subrange +TESTBOUNDARY + +# Terms from HTTPRuntimeTestHelpers planning and scheme configuration. +fileprivate +parallelizable +Testables +xcscheme +xcschemes +xcshareddata +xcworkspace + # Non-ASCII fixture data used to exercise Unicode-handling and URL-encoding # edge cases (BuildURLRequestTests, HTTPErrorTests). Cigányka 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/plans/2026-07-11-http-runtime-test-helpers.md b/docs/superpowers/plans/2026-07-11-http-runtime-test-helpers.md new file mode 100644 index 000000000..67f98b242 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-http-runtime-test-helpers.md @@ -0,0 +1,1079 @@ +# HTTPRuntimeTestHelpers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `HTTPRuntimeTestHelpers` SPM target providing first-party Swift Testing support for stubbing `HTTPTransport`-issued requests (`.http(stubs:)` trait) and asserting the shape of outgoing requests (`assertHTTPRequests`). + +**Architecture:** A `HTTPTransportStub` actor conforms to `HTTPTransport` and holds an ordered, consume-once stub queue plus a log of every request it has seen. A `TestScoping` trait (`.http(stubs:)`) binds it to a `@TaskLocal` for the duration of a test — isolated per task tree, so parallel `swift test` runs never share state. Tests read `HTTPTransportStub.current` and pass it explicitly to the client under test (constructor injection, same as production code). `assertHTTPRequests` wraps an operation and asserts an inline curl snapshot of whatever requests it made, read back from the same transport. + +**Tech Stack:** Swift 6.1, Swift Testing (`Testing`), `InlineSnapshotTesting` (already a repo dependency via `swift-snapshot-testing`), the existing `HTTPRuntime` target. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-11-http-runtime-test-helpers-design.md` — read it before starting; this plan implements it exactly. +- Every declaration in `HTTPRuntimeTestHelpers` is `package`-scoped, never `public` — this target is test support, not shipped API, and stray `public` symbols trip the repo's "Check public API against capability matrix" CI job (this bit HTTPRuntime once already; see `Sources/HTTPRuntime/TransferProgress.swift` history). +- No dependency on `Sources/TestHelpers` — `HTTPRuntime` and its test helpers are deliberately low-dependency, standalone targets. +- Not a wrapper around `Replay` — `HTTPTransportStub` mocks at the `HTTPTransport` protocol level, independent of `Replay`'s `URLSession`-fetch-closure mocking used by Auth/PostgREST/Functions. +- Run `./scripts/format.sh` before every commit in this plan (swift-format). Run `./scripts/spell-check.sh` before the final commit and add any flagged word to `dictionary.txt` under a new "Terms from HTTPRuntimeTestHelpers" section. +- Test files: Swift Testing only (`@Suite`, `@Test`, `#expect`), test function names drop the `test` prefix, per `AGENTS.md`. +- File headers follow the existing convention: `//\n// FileName.swift\n// ModuleName\n//\n// Created by Guilherme Souza on 11/07/26.\n//`. + +--- + +## File Structure + +- `Package.swift` — add `HTTPRuntimeTestHelpers` target + `HTTPRuntimeTestHelpersTests` test target. +- `Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift` — the `HTTPStubBody` enum (canned response body shapes). +- `Sources/HTTPRuntimeTestHelpers/HTTPStub.swift` — the `HTTPStub` struct + one static factory per HTTP verb. +- `Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift` — `HTTPStubMismatch` error, the `HTTPTransportStub` actor (queue/match/consume/leftover/`current`), the `HTTPStubTrait` type, and the `http(stubs:)` free function. These last two live in this file (not their own) because `HTTPStubTrait.provideScope` needs `fileprivate` access to `HTTPTransportStub`'s `_current` TaskLocal and `remainingStubs`. +- `Sources/HTTPRuntimeTestHelpers/CurlCommand.swift` — `curlCommand(for:)`, rendering an `HTTPRequest` as a curl command. +- `Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift` — the `assertHTTPRequests` function. +- `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift` +- `Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift` +- `Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift` +- `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift` +- `Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift` +- `Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme` — register `HTTPRuntimeTestHelpersTests`. +- `dictionary.txt` — any new cspell-flagged words. + +--- + +### Task 1: Scaffold the target + `HTTPStubBody`/`HTTPStub` + +**Files:** +- Modify: `Package.swift` +- Create: `Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift` +- Create: `Sources/HTTPRuntimeTestHelpers/HTTPStub.swift` +- Test: `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift` + +**Interfaces:** +- Produces: `HTTPStubBody` (`.empty`, `.string(String)`, `.data(Data)`, `.stream(AsyncStream)`); `HTTPStub` with `package let method: HTTPMethod`, `url: String`, `status: Int`, `headers: [String: String]`, `body: @Sendable () -> HTTPStubBody`, and static factories `.get`/`.post`/`.put`/`.patch`/`.delete`/`.head(_:status:headers:body:)`. + +- [ ] **Step 1: Add the two new targets to `Package.swift`** + +Find this block (the existing `HTTPRuntime`/`HTTPRuntimeTests` target pair): + +```swift + .target( + name: "HTTPRuntime" + ), + .testTarget( + name: "HTTPRuntimeTests", + dependencies: [ + "HTTPRuntime" + ] + ), +``` + +Replace it with: + +```swift + .target( + name: "HTTPRuntime" + ), + .testTarget( + name: "HTTPRuntimeTests", + dependencies: [ + "HTTPRuntime" + ] + ), + .target( + name: "HTTPRuntimeTestHelpers", + dependencies: [ + "HTTPRuntime", + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + ] + ), + .testTarget( + name: "HTTPRuntimeTestHelpersTests", + dependencies: [ + "HTTPRuntimeTestHelpers" + ] + ), +``` + +Then find: + +```swift +let swift6TestTargets: Set = ["SupabaseTests", "HelpersTests", "HTTPRuntimeTests"] +``` + +Replace it with: + +```swift +let swift6TestTargets: Set = [ + "SupabaseTests", "HelpersTests", "HTTPRuntimeTests", "HTTPRuntimeTestHelpersTests", +] +``` + +- [ ] **Step 2: Write `HTTPStubBody`** + +Create `Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift`: + +```swift +// +// HTTPStubBody.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation + +/// The canned response body for an ``HTTPStub``. +package enum HTTPStubBody: Sendable { + case empty + case string(String) + case data(Data) + /// Chunks delivered over time — for stubbing `HTTPTransport.stream()`. + case stream(AsyncStream) +} +``` + +- [ ] **Step 3: Write `HTTPStub`** + +Create `Sources/HTTPRuntimeTestHelpers/HTTPStub.swift`: + +```swift +// +// HTTPStub.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import HTTPRuntime + +/// A canned response for one request, matched by HTTP method + full URL +/// (including query), consumed in the order it appears in `.http(stubs:)`'s +/// array. Only ever describes the *response* — see `assertHTTPRequests` to +/// assert the shape of the outgoing request. +package struct HTTPStub: Sendable { + package let method: HTTPMethod + package let url: String + package let status: Int + package let headers: [String: String] + package let body: @Sendable () -> HTTPStubBody + + private init( + method: HTTPMethod, url: String, status: Int, headers: [String: String], + body: @escaping @Sendable () -> HTTPStubBody + ) { + self.method = method + self.url = url + self.status = status + self.headers = headers + self.body = body + } + + package static func get( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .get, url: url, status: status, headers: headers, body: body) + } + + package static func post( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .post, url: url, status: status, headers: headers, body: body) + } + + package static func put( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .put, url: url, status: status, headers: headers, body: body) + } + + package static func patch( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .patch, url: url, status: status, headers: headers, body: body) + } + + package static func delete( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .delete, url: url, status: status, headers: headers, body: body) + } + + package static func head( + _ url: String, status: Int = 200, headers: [String: String] = [:], + body: @escaping @Sendable () -> HTTPStubBody = { .empty } + ) -> HTTPStub { + HTTPStub(method: .head, url: url, status: status, headers: headers, body: body) + } +} +``` + +- [ ] **Step 4: Write the failing test** + +Create `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift`: + +```swift +// +// HTTPStubTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Testing + +@testable import HTTPRuntimeTestHelpers + +@Suite +struct HTTPStubTests { + @Test + func getBuildsExpectedStub() { + let stub = HTTPStub.get("https://example.com/x", status: 201, headers: ["X-Test": "1"]) { + .string("hello") + } + #expect(stub.method == .get) + #expect(stub.url == "https://example.com/x") + #expect(stub.status == 201) + #expect(stub.headers == ["X-Test": "1"]) + guard case .string(let value) = stub.body() else { + Issue.record("expected .string body") + return + } + #expect(value == "hello") + } + + @Test + func defaultsToStatus200AndEmptyBody() { + let stub = HTTPStub.post("https://example.com/y") + #expect(stub.status == 200) + #expect(stub.headers.isEmpty) + guard case .empty = stub.body() else { + Issue.record("expected .empty body") + return + } + } + + @Test + func everyVerbFactoryProducesItsMethod() { + #expect(HTTPStub.get("https://example.com").method == .get) + #expect(HTTPStub.post("https://example.com").method == .post) + #expect(HTTPStub.put("https://example.com").method == .put) + #expect(HTTPStub.patch("https://example.com").method == .patch) + #expect(HTTPStub.delete("https://example.com").method == .delete) + #expect(HTTPStub.head("https://example.com").method == .head) + } +} +``` + +This step is written after Steps 1–3 (unlike the canonical write-test-first order) because `Package.swift` needs both the target and at least one source file to exist before `swift build`/`swift test` can even resolve the new module — there is no meaningful "compile-fails" checkpoint before that scaffolding exists. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests` +Expected: `Test run with 3 tests in 1 suite passed` (or similar — 3 tests, 0 failures). + +- [ ] **Step 6: Commit** + +```bash +git add Package.swift Sources/HTTPRuntimeTestHelpers/HTTPStubBody.swift Sources/HTTPRuntimeTestHelpers/HTTPStub.swift Tests/HTTPRuntimeTestHelpersTests/HTTPStubTests.swift +git commit -m "feat(runtime): scaffold HTTPRuntimeTestHelpers, add HTTPStubBody/HTTPStub" +``` + +--- + +### Task 2: `HTTPTransportStub` — the ordered, consume-once stub queue + +**Files:** +- Create: `Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift` +- Test: `Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift` + +**Interfaces:** +- Consumes: `HTTPStub` (Task 1) — `method`, `url`, `status`, `headers`, `body: @Sendable () -> HTTPStubBody`. `HTTPTransport` protocol from `HTTPRuntime` (`send(_:uploadProgress:)`, `stream(_:)`, both `async throws(HTTPError)`). `HTTPRequest`, `HTTPResponse`, `HTTPResponseHead`, `HTTPResponseStream`, `HTTPError.transport(any Error)`, `ProgressHandler` from `HTTPRuntime`. +- Produces: `package actor HTTPTransportStub: HTTPTransport` with `package init(stubs: [HTTPStub])`, `package static var current: HTTPTransportStub`, `package func assertAllConsumed()`, `package var requestCount: Int`, `package func requests(since index: Int) -> [HTTPRequest]`. Also a `fileprivate` TaskLocal `_current` and `fileprivate var remainingStubs: [HTTPStub]` — both consumed by Task 4's `HTTPStubTrait`, which is appended to this same file. + +- [ ] **Step 1: Write the failing tests** + +Create `Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift`: + +```swift +// +// HTTPTransportStubTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import Testing + +@testable import HTTPRuntimeTestHelpers +import HTTPRuntime + +@Suite +struct HTTPTransportStubTests { + @Test + func matchesAndReturnsStubbedResponse() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a", status: 201, headers: ["X": "1"]) { .string("hi") } + ]) + let response = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + #expect(response.head.status == 201) + #expect(response.head.headers == ["X": "1"]) + #expect(response.body == Data("hi".utf8)) + } + + @Test + func consumesStubsInOrder() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a") { .string("first") }, + .get("https://example.com/b") { .string("second") }, + ]) + let first = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + let second = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/b")!), uploadProgress: nil) + #expect(first.body == Data("first".utf8)) + #expect(second.body == Data("second".utf8)) + } + + @Test + func mismatchRecordsIssueAndThrows() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/expected") { .empty } + ]) + await withKnownIssue { + _ = try await transport.send( + HTTPRequest(method: .post, url: URL(string: "https://example.com/actual")!), uploadProgress: nil) + } + } + + @Test + func exhaustedQueueRecordsIssueAndThrows() async throws { + let transport = HTTPTransportStub(stubs: []) + await withKnownIssue { + _ = try await transport.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + } + } + + @Test + func assertAllConsumedRecordsIssueForLeftoverStubs() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/never-called") { .empty } + ]) + await withKnownIssue { + await transport.assertAllConsumed() + } + } + + @Test + func currentOutsideScopeRecordsIssueAndReturnsUsableTransport() async throws { + await withKnownIssue { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + } + } + + @Test + func streamYieldsStubbedChunks() async throws { + let transport = HTTPTransportStub(stubs: [ + .get("https://example.com/a") { .data(Data("chunk".utf8)) } + ]) + let responseStream = try await transport.stream( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!)) + var collected = Data() + for try await chunk in responseStream.body { collected.append(chunk) } + #expect(collected == Data("chunk".utf8)) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.HTTPTransportStubTests` +Expected: FAIL — `cannot find 'HTTPTransportStub' in scope` (the type doesn't exist yet). + +- [ ] **Step 3: Implement `HTTPTransportStub`** + +Create `Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift`: + +```swift +// +// HTTPTransportStub.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime +import Testing + +/// Thrown into `HTTPError.transport` on a stub mismatch — the actual test +/// failure is the `Issue.record` call alongside it; this just gives the code +/// under test a real error to handle if it inspects the failure. +package struct HTTPStubMismatch: Error, CustomStringConvertible { + package let description: String +} + +/// The `HTTPTransport` backing `.http(stubs:)` — an ordered, consume-once +/// stub queue. Bound to the current task tree via `HTTPStubTrait` (below). +package actor HTTPTransportStub: HTTPTransport { + @TaskLocal fileprivate static var _current: HTTPTransportStub? + + /// The stub transport bound by the enclosing `.http(stubs:)` trait scope. + /// Outside such a scope, accessing this records an issue and returns an + /// empty-queue instance — any request against it fails through the normal + /// "no stubs remaining" path below rather than crashing. + package static var current: HTTPTransportStub { + guard let value = _current else { + Issue.record("HTTPTransportStub.current accessed outside a .http trait scope") + return HTTPTransportStub(stubs: []) + } + return value + } + + private var pending: [HTTPStub] + private var consumedRequests: [HTTPRequest] = [] + + package init(stubs: [HTTPStub]) { + pending = stubs + } + + private func nextMatchingStub(for request: HTTPRequest) throws(HTTPError) -> HTTPStub { + consumedRequests.append(request) + guard !pending.isEmpty else { + let message = + "Unexpected request \(request.method.rawValue) \(request.url.absoluteString) — no stubs remaining" + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + let stub = pending.removeFirst() + guard stub.method == request.method, stub.url == request.url.absoluteString else { + let message = """ + Request mismatch. + Expected: \(stub.method.rawValue) \(stub.url) + Actual: \(request.method.rawValue) \(request.url.absoluteString) + """ + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + return stub + } + + package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) + -> HTTPResponse + { + let stub = try nextMatchingStub(for: request) + let bodyData: Data + switch stub.body() { + case .empty: + bodyData = Data() + case .string(let value): + bodyData = Data(value.utf8) + case .data(let value): + bodyData = value + case .stream(let stream): + var collected = Data() + for await chunk in stream { collected.append(chunk) } + bodyData = collected + } + return HTTPResponse(head: HTTPResponseHead(status: stub.status, headers: stub.headers), body: bodyData) + } + + package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + let stub = try nextMatchingStub(for: request) + let responseBody: AsyncThrowingStream + switch stub.body() { + case .empty: + responseBody = AsyncThrowingStream { $0.finish() } + case .string(let value): + responseBody = AsyncThrowingStream { continuation in + continuation.yield(Data(value.utf8)) + continuation.finish() + } + case .data(let value): + responseBody = AsyncThrowingStream { continuation in + continuation.yield(value) + continuation.finish() + } + case .stream(let stream): + responseBody = AsyncThrowingStream { continuation in + let task = Task { + for await chunk in stream { continuation.yield(chunk) } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + return HTTPResponseStream( + head: HTTPResponseHead(status: stub.status, headers: stub.headers), body: responseBody) + } + + /// Records an issue for every stub that was never consumed. Called by + /// `HTTPStubTrait` at scope exit. + package func assertAllConsumed() { + for stub in pending { + Issue.record("Stub for \(stub.method.rawValue) \(stub.url) was never consumed") + } + } + + /// Count of requests recorded so far — `assertHTTPRequests` snapshots this + /// before running its operation, then diffs against it after. + package var requestCount: Int { consumedRequests.count } + + /// Requests recorded from `index` onward. + package func requests(since index: Int) -> [HTTPRequest] { Array(consumedRequests[index...]) } + + /// Stubs not yet consumed — read by `HTTPStubTrait` (below) to merge a + /// suite-level queue with a nested test-level one. + fileprivate var remainingStubs: [HTTPStub] { pending } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.HTTPTransportStubTests` +Expected: `Test run with 7 tests in 1 suite passed`. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift Tests/HTTPRuntimeTestHelpersTests/HTTPTransportStubTests.swift +git commit -m "feat(runtime): add HTTPTransportStub, the consume-once stub queue" +``` + +--- + +### Task 3: `curlCommand(for:)` + +**Files:** +- Create: `Sources/HTTPRuntimeTestHelpers/CurlCommand.swift` +- Test: `Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift` + +**Interfaces:** +- Consumes: `HTTPRequest` (`method: HTTPMethod`, `url: URL`, `headers: [String: String]`, `body: HTTPBody?`) from `HTTPRuntime`. +- Produces: `package func curlCommand(for request: HTTPRequest) -> String`, used by Task 5's `assertHTTPRequests`. + +- [ ] **Step 1: Write the failing tests** + +Create `Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift`: + +```swift +// +// CurlCommandTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import Testing + +@testable import HTTPRuntimeTestHelpers +import HTTPRuntime + +@Suite +struct CurlCommandTests { + @Test + func rendersGetWithSortedHeadersAndQuery() { + let request = HTTPRequest( + method: .get, + url: URL(string: "https://example.com/x?b=2&a=1")!, + headers: ["Content-Type": "application/json", "Accept": "application/json"]) + #expect( + curlCommand(for: request) == """ + curl \\ + \t--header "Accept: application/json" \\ + \t--header "Content-Type: application/json" \\ + \t"https://example.com/x?a=1&b=2" + """) + } + + @Test + func rendersPostWithEscapedBody() { + let request = HTTPRequest( + method: .post, + url: URL(string: "https://example.com/x")!, + headers: [:], + body: .data(Data(#"{"a":1}"#.utf8))) + #expect( + curlCommand(for: request) == #""" + curl \ + --request POST \ + --data "{\"a\":1}" \ + "https://example.com/x" + """#) + } + + @Test + func rendersHead() { + let request = HTTPRequest(method: .head, url: URL(string: "https://example.com/x")!) + #expect( + curlCommand(for: request) == """ + curl \\ + \t--head \\ + \t"https://example.com/x" + """) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.CurlCommandTests` +Expected: FAIL — `cannot find 'curlCommand' in scope`. + +- [ ] **Step 3: Implement `curlCommand(for:)`** + +Create `Sources/HTTPRuntimeTestHelpers/CurlCommand.swift`: + +```swift +// +// CurlCommand.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import HTTPRuntime + +/// Renders an `HTTPRequest` as a curl command — method, sorted headers, +/// escaped body, sorted query items. Mirrors the conventions of +/// `Sources/TestHelpers/URLRequestSnapshot.swift`'s `._curl` strategy for +/// `URLRequest`, implemented independently against `HTTPRequest` so this +/// target has no dependency on `TestHelpers`. `.file` request bodies aren't +/// rendered (no `--data` line) — out of scope for this helper's JSON-body +/// use case. +package func curlCommand(for request: HTTPRequest) -> String { + var components = ["curl"] + + switch request.method { + case .get: break + case .head: components.append("--head") + default: components.append("--request \(request.method.rawValue)") + } + + for field in request.headers.keys.sorted() where field != "Cookie" { + let escapedValue = request.headers[field]!.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--header \"\(field): \(escapedValue)\"") + } + + if case .data(let data) = request.body, let httpBody = String(data: data, encoding: .utf8) { + var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") + escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--data \"\(escapedBody)\"") + } + + if let cookie = request.headers["Cookie"] { + let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--cookie \"\(escapedValue)\"") + } + + components.append("\"\(sortedQueryURL(request.url).absoluteString)\"") + + return components.joined(separator: " \\\n\t") +} + +private func sortedQueryURL(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems + else { + return url + } + components.queryItems = queryItems.sorted { $0.name < $1.name } + return components.url ?? url +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.CurlCommandTests` +Expected: `Test run with 3 tests in 1 suite passed`. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/HTTPRuntimeTestHelpers/CurlCommand.swift Tests/HTTPRuntimeTestHelpersTests/CurlCommandTests.swift +git commit -m "feat(runtime): add curlCommand(for:) request formatter" +``` + +--- + +### Task 4: `HTTPStubTrait` + `http(stubs:)` + +**Files:** +- Modify: `Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift` (append to the end of the file) +- Test: `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift` + +**Interfaces:** +- Consumes: `HTTPTransportStub` (Task 2) — its `fileprivate` `_current` TaskLocal, `init(stubs:)`, `remainingStubs`, `assertAllConsumed()`. `Testing.TestTrait`, `Testing.SuiteTrait`, `Testing.TestScoping`, `Testing.Test`, `Testing.Test.Case` from the `Testing` module. +- Produces: `package func http(stubs: [HTTPStub]) -> HTTPStubTrait`, usable as `@Test(.http(stubs: [...]))` or `@Suite(.http(stubs: [...]))`. + +- [ ] **Step 1: Write the failing tests** + +Create `Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift`: + +```swift +// +// HTTPStubTraitTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import Testing + +@testable import HTTPRuntimeTestHelpers +import HTTPRuntime + +@Suite +struct HTTPStubTraitTests { + @Test(.http(stubs: [.get("https://example.com/x", status: 200) { .string("ok") }])) + func bindsCurrentForTestBody() async throws { + let response = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/x")!), uploadProgress: nil) + #expect(response.body == Data("ok".utf8)) + } + + @Test + func leftoverStubRecordsIssueAtScopeExit() async throws { + let trait = http(stubs: [.get("https://example.com/never-called") { .empty }]) + await withKnownIssue { + try await trait.provideScope(for: Test.current!, testCase: Test.Case.current) { + // Deliberately consume nothing. + } + } + } + + @Test + func suiteAndTestStubsMergeInOrder() async throws { + let suiteLevelTrait = http(stubs: [.get("https://example.com/first") { .string("1") }]) + let testLevelTrait = http(stubs: [.get("https://example.com/second") { .string("2") }]) + try await suiteLevelTrait.provideScope(for: Test.current!, testCase: Test.Case.current) { + try await testLevelTrait.provideScope(for: Test.current!, testCase: Test.Case.current) { + let first = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/first")!), uploadProgress: nil) + let second = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/second")!), uploadProgress: nil) + #expect(first.body == Data("1".utf8)) + #expect(second.body == Data("2".utf8)) + } + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.HTTPStubTraitTests` +Expected: FAIL — `type 'HTTPStubTrait' has no member 'http'` / `cannot infer contextual base in reference to member 'http'` (the trait and free function don't exist yet). + +- [ ] **Step 3: Implement `HTTPStubTrait` + `http(stubs:)`** + +Append to the end of `Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift`: + +```swift + +/// Declares canned responses for `HTTPTransport`-issued requests made during +/// a test. Usable at `@Test` or `@Suite` level; a `@Test`-level trait appends +/// its stubs to whatever an enclosing `@Suite`-level trait already queued, +/// preserving order. +package struct HTTPStubTrait: TestTrait, SuiteTrait, TestScoping { + package let isRecursive = true + + fileprivate let stubs: [HTTPStub] + + package func provideScope( + for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void + ) async throws { + let outerStubs = await HTTPTransportStub._current?.remainingStubs ?? [] + let transport = HTTPTransportStub(stubs: outerStubs + stubs) + try await HTTPTransportStub.$_current.withValue(transport) { + try await function() + await transport.assertAllConsumed() + } + } +} + +/// `@Test(.http(stubs: [.get("https://example.com/x") { .string("...") }]))` +package func http(stubs: [HTTPStub]) -> HTTPStubTrait { + HTTPStubTrait(stubs: stubs) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.HTTPStubTraitTests` +Expected: `Test run with 3 tests in 1 suite passed`. + +- [ ] **Step 5: Run the full HTTPRuntimeTestHelpersTests suite to check for regressions** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests` +Expected: all tests from Tasks 1–4 pass (13 tests total: 3 + 7 + 3, `HTTPStubTraitTests` not yet counted — actual total 16 across 4 suites). + +- [ ] **Step 6: Commit** + +```bash +git add Sources/HTTPRuntimeTestHelpers/HTTPTransportStub.swift Tests/HTTPRuntimeTestHelpersTests/HTTPStubTraitTests.swift +git commit -m "feat(runtime): add HTTPStubTrait and the .http(stubs:) trait" +``` + +--- + +### Task 5: `assertHTTPRequests` + +**Files:** +- Create: `Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift` +- Test: `Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift` + +**Interfaces:** +- Consumes: `HTTPTransportStub.current`, `.requestCount`, `.requests(since:)` (Task 2); `curlCommand(for:)` (Task 3); `InlineSnapshotTesting.assertInlineSnapshot`, `Snapshotting.lines`, `InlineSnapshotSyntaxDescriptor`. +- Produces: `package func assertHTTPRequests(fileID:filePath:function:line:column:_:matches:) async throws -> R`, called as `try await assertHTTPRequests { operation } matches: { snapshot }`. + +This task relies on `InlineSnapshotTesting`'s real recording behavior: when `matches:` is omitted, the **first** run intentionally fails (`"Automatically recorded a new snapshot. Re-run ... to assert against the newly-recorded snapshot."`) while rewriting the calling test file's source to insert the recorded literal; the **second** run then passes. This is the standard, documented way this library is used (already the pattern behind `assertSnapshot(of: request, as: .curl, ...)` elsewhere in this repo) — the steps below follow that two-run flow instead of the canonical single "write code, run once, pass" shape. + +- [ ] **Step 1: Write the tests without a recorded snapshot yet** + +Create `Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift`: + +```swift +// +// AssertHTTPRequestsTests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +import Foundation +import Testing + +@testable import HTTPRuntimeTestHelpers +import HTTPRuntime + +@Suite +struct AssertHTTPRequestsTests { + @Test( + .http(stubs: [ + .get("https://example.com/a") { .empty }, + .get("https://example.com/b") { .empty }, + .get("https://example.com/c") { .empty }, + ])) + func onlyCapturesRequestsMadeDuringItsOwnOperation() async throws { + // Fires one request *before* any assertHTTPRequests call — must not leak + // into the slice captured below. + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/a")!), uploadProgress: nil) + + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/b")!), uploadProgress: nil) + } + + // A second call must only see requests made after the first one returned. + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/c")!), uploadProgress: nil) + } + } + + @Test( + .http(stubs: [ + .get("https://example.com/first") { .empty }, + .post("https://example.com/second") { .empty }, + ])) + func rendersMultipleRequestsJoinedByBlankLine() async throws { + try await assertHTTPRequests { + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .get, url: URL(string: "https://example.com/first")!), uploadProgress: nil) + _ = try await HTTPTransportStub.current.send( + HTTPRequest(method: .post, url: URL(string: "https://example.com/second")!), uploadProgress: nil) + } + } +} +``` + +Note both `assertHTTPRequests` calls are written **without** a `matches:` trailing closure — that's intentional, see the recording flow above. + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.AssertHTTPRequestsTests` +Expected: FAIL — `cannot find 'assertHTTPRequests' in scope`. + +- [ ] **Step 3: Implement `assertHTTPRequests`** + +Create `Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift`: + +```swift +// +// AssertHTTPRequests.swift +// HTTPRuntimeTestHelpers +// +// Created by Guilherme Souza on 11/07/26. +// +@preconcurrency import InlineSnapshotTesting +import HTTPRuntime + +/// Runs `operation`, then asserts an inline curl snapshot of every request +/// `operation` made against the ambient `HTTPTransportStub.current` — i.e. +/// this must run inside a `.http(stubs:)` scope. Multiple requests made +/// during `operation` render as multiple curl commands joined by a blank +/// line, in call order. +package func assertHTTPRequests( + fileID: StaticString = #fileID, filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, column: UInt = #column, + _ operation: () async throws -> R, + matches expected: (() -> String)? = nil +) async throws -> R { + let transport = HTTPTransportStub.current + let startIndex = await transport.requestCount + let result = try await operation() + let requests = await transport.requests(since: startIndex) + let rendered = requests.map(curlCommand(for:)).joined(separator: "\n\n") + assertInlineSnapshot( + of: rendered, as: .lines, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor(trailingClosureOffset: 1), + matches: expected, + fileID: fileID, file: filePath, function: function, line: line, column: column) + return result +} +``` + +- [ ] **Step 4: Run the tests once to auto-record the snapshots** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.AssertHTTPRequestsTests` +Expected: FAIL, with messages containing `"Automatically recorded a new snapshot. Re-run ... to assert against the newly-recorded snapshot."` — one per `assertHTTPRequests` call (3 total: 2 in the first test, 1 in the second). This run also rewrites `Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift` in place, inserting a `matches: { ... }` trailing closure at each call site with the recorded curl text. + +- [ ] **Step 5: Inspect the recorded snapshots** + +Run: `git diff Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift` +Expected: each `assertHTTPRequests { ... }` call now has an inserted `matches: { """ curl ... """ }` trailing closure. Confirm: +- The first test's first snapshot renders only `https://example.com/b` (not `/a`). +- The first test's second snapshot renders only `https://example.com/c` (not `/a` or `/b`). +- The second test's snapshot renders two curl blocks (`/first` then `--request POST .../second`) separated by a blank line. + +If any of these don't hold, the implementation in Step 3 has a bug — fix it and repeat from Step 4. Do not hand-edit the recorded snapshot text. + +- [ ] **Step 6: Run the tests again to verify they now pass** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests.AssertHTTPRequestsTests` +Expected: `Test run with 2 tests in 1 suite passed`. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/HTTPRuntimeTestHelpers/AssertHTTPRequests.swift Tests/HTTPRuntimeTestHelpersTests/AssertHTTPRequestsTests.swift +git commit -m "feat(runtime): add assertHTTPRequests for request-shape assertions" +``` + +--- + +### Task 6: Xcode scheme, spell-check, and final verification + +**Files:** +- Modify: `Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme` +- Modify: `dictionary.txt` (only if spell-check flags new words) + +**Interfaces:** None — this task wires up tooling around the code from Tasks 1–5, it doesn't add new symbols. + +- [ ] **Step 1: Register `HTTPRuntimeTestHelpersTests` in the shared Xcode scheme** + +In `Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme`, find this block (inside the `` → `` section, right after `HelpersTests` and right before `HTTPRuntimeTests`): + +```xml + + + + + + + + +``` + +Insert a new `TestableReference` for `HTTPRuntimeTestHelpersTests` between them (alphabetical order: `HelpersTests` < `HTTPRuntimeTestHelpersTests` < `HTTPRuntimeTests`), so the block becomes: + +```xml + + + + + + + + + + + + +``` + +- [ ] **Step 2: Confirm no `public` declarations leaked into the new target** + +Run: `grep -rn "^public\| public " Sources/HTTPRuntimeTestHelpers/` +Expected: no output (every declaration is `package`). + +- [ ] **Step 3: Run the full test suite for both new targets** + +Run: `swift test --filter HTTPRuntimeTestHelpersTests` +Expected: all tests across `HTTPStubTests`, `HTTPTransportStubTests`, `CurlCommandTests`, `HTTPStubTraitTests`, `AssertHTTPRequestsTests` pass (18 tests total: 3 + 7 + 3 + 3 + 2, across 5 suites). +Also run: `swift build` +Expected: builds cleanly with no warnings from the new target. + +- [ ] **Step 4: Format** + +Run: `./scripts/format.sh` +Expected: exits 0. If it reformats any file in `Sources/HTTPRuntimeTestHelpers/` or `Tests/HTTPRuntimeTestHelpersTests/`, review the diff (should be whitespace-only) and keep it. + +- [ ] **Step 5: Spell-check** + +Run: `npm ci --prefix tools/node` (only if `tools/node/package-lock.json` changed since last run — otherwise skip), then `./scripts/spell-check.sh` +Expected: exits 0. If it flags a word introduced by this plan (e.g. an identifier fragment cspell doesn't recognize), add it to `dictionary.txt` under a new section: + +``` +# Terms from HTTPRuntimeTestHelpers (Sources/HTTPRuntimeTestHelpers) and its tests. + +``` + +Re-run `./scripts/spell-check.sh` until it exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add Supabase.xcworkspace/xcshareddata/xcschemes/Supabase.xcscheme dictionary.txt +git commit -m "chore(ci): register HTTPRuntimeTestHelpersTests in the Supabase scheme and dictionary" +``` + +If Step 5 didn't touch `dictionary.txt`, drop it from the `git add`/commit — don't commit a no-op change to that file. 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). diff --git a/docs/superpowers/specs/2026-07-11-http-runtime-test-helpers-design.md b/docs/superpowers/specs/2026-07-11-http-runtime-test-helpers-design.md new file mode 100644 index 000000000..7d6b68a71 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-http-runtime-test-helpers-design.md @@ -0,0 +1,277 @@ +# HTTPRuntimeTestHelpers design + +## Goal + +First-party Swift Testing support for stubbing `HTTPTransport`-issued requests: a +custom trait (`.http(stubs:)`) that lets a test declare canned responses and +works under parallel test execution, plus a standalone `assertHTTPRequests` +helper to assert the shape of the outgoing request(s) via an inline curl +snapshot. Response stubbing and request-shape assertion are separate +concerns. + +## Non-goals + +- Not a wrapper around [Replay](https://github.com/mattt/Replay). Replay mocks + at the `URLSession`-fetch-closure level for Auth/PostgREST/Functions; this + target mocks at the `HTTPTransport` protocol level and is independent. +- Not a general-purpose HTTP mocking library for use outside this repo's + `HTTPRuntime`-based clients. + +## Target + +New SPM target `HTTPRuntimeTestHelpers` (test-support, not shipped to +consumers), depending on `HTTPRuntime`, `Testing`, and `InlineSnapshotTesting`. +Deliberately does **not** depend on the app-level `Sources/TestHelpers` target +— `HTTPRuntime` is a standalone, low-dependency package, and its test helpers +should mirror that. + +## Components + +### `HTTPStubBody` + +```swift +public enum HTTPStubBody: Sendable { + case empty + case string(String) + case data(Data) + case stream(AsyncStream) // for stubbing HTTPTransport.stream() +} +``` + +### `HTTPStub` + +One static factory method per HTTP verb (`.get`, `.post`, `.put`, `.patch`, +`.delete`, `.head`). Matches the existing codebase convention of static +factories over a single parameterized initializer. + +```swift +public struct HTTPStub: Sendable { + public static func post( + _ url: String, + status: Int = 200, + headers: [String: String] = [:], + body: @Sendable @escaping () -> HTTPStubBody = { .empty } + ) -> HTTPStub + // .get, .put, .patch, .delete, .head — same shape +} +``` + +- `url` is compared against the full outgoing URL string, including query, + exactly (no path-only or pattern matching). +- `HTTPStub` only ever describes the canned *response*. Asserting the shape + of the outgoing *request* is a separate concern — see `assertHTTPRequests` + below. + +### `HTTPTransportStub` + +The `HTTPTransport` conformance that backs the trait. An ordered, +consume-once queue: each call to `send`/`stream` pops the **next** stub (not a +search across all remaining stubs) and compares method + full URL. A mismatch +still consumes that slot — later calls will cascade into further mismatches, +which is intentional; the first recorded issue is the actionable one. + +```swift +/// Thrown into `HTTPError.transport` on a stub mismatch — the actual test +/// failure is the `Issue.record` call alongside it, this just gives the code +/// under test a real error to handle if it inspects the failure. +struct HTTPStubMismatch: Error, CustomStringConvertible { + let description: String +} + +package actor HTTPTransportStub: HTTPTransport { + @TaskLocal private static var _current: HTTPTransportStub? + + package static var current: HTTPTransportStub { + guard let value = _current else { + Issue.record("HTTPTransportStub.current accessed outside a .http trait scope") + return HTTPTransportStub(stubs: []) + } + return value + } + + private var pending: [HTTPStub] + private var consumedRequests: [HTTPRequest] = [] + init(stubs: [HTTPStub]) { pending = stubs } + + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) -> HTTPResponse { + consumedRequests.append(request) + guard !pending.isEmpty else { + let message = "Unexpected request \(request.method) \(request.url) — no stubs remaining" + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + let stub = pending.removeFirst() + guard stub.method == request.method, stub.url == request.url.absoluteString else { + let message = """ + Request mismatch. + Expected: \(stub.method) \(stub.url) + Actual: \(request.method) \(request.url) + """ + Issue.record("\(message)") + throw HTTPError.transport(HTTPStubMismatch(description: message)) + } + return HTTPResponse(head: HTTPResponseHead(status: stub.status, headers: stub.headers), body: stub.bodyData) + } + + func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + // same pop/compare/record logic as send, yields .stream(AsyncStream) chunks + } + + func assertAllConsumed() { + for stub in pending { + Issue.record("Stub for \(stub.method) \(stub.url) was never consumed") + } + } + + /// Count of requests recorded so far — `assertHTTPRequests` snapshots this + /// before running its operation, then diffs against it after. + var requestCount: Int { consumedRequests.count } + + /// Requests recorded from `index` onward. + func requests(since index: Int) -> [HTTPRequest] { Array(consumedRequests[index...]) } + + /// Stubs not yet consumed — read by `HTTPStubTrait` to merge a suite-level + /// queue with a nested test-level one. + fileprivate var remainingStubs: [HTTPStub] { pending } +} +``` + +`current` is always non-optional. Outside a `.http` scope, accessing it +records an issue immediately (via `Issue.record`) and hands back an +empty-queue instance — any subsequent request against it fails through the +normal "no stubs remaining" path rather than crashing. + +### `curlCommand(for:)` and `assertHTTPRequests` + +A single formatting function renders an `HTTPRequest` as a curl command +(method, URL, sorted headers, escaped body) — mirroring the conventions of +the existing `Sources/TestHelpers/URLRequestSnapshot.swift` `._curl` strategy +for consistency across the codebase's curl-snapshot output, but implemented +independently against `HTTPRequest` so `HTTPRuntimeTestHelpers` has no +dependency on `TestHelpers`. + +```swift +func curlCommand(for request: HTTPRequest) -> String { ... } + +func assertHTTPRequests( + fileID: StaticString = #fileID, filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, column: UInt = #column, + _ operation: () async throws -> R, + matches expected: (() -> String)? = nil +) async throws -> R { + let transport = HTTPTransportStub.current + let startIndex = await transport.requestCount + let result = try await operation() + let requests = await transport.requests(since: startIndex) + let rendered = requests.map(curlCommand(for:)).joined(separator: "\n\n") + assertInlineSnapshot( + of: rendered, as: .lines, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor(trailingClosureOffset: 1), + matches: expected, + fileID: fileID, file: filePath, function: function, line: line, column: column) + return result +} +``` + +`assertHTTPRequests` requires an ambient `HTTPTransportStub.current` (i.e. +must run inside a `.http(stubs:)` scope) — it reads back whatever requests +that transport recorded while consuming stubs during `operation()`, so +there's one source of truth for "what requests happened," not two competing +mechanisms. Multiple requests in one `operation()` render as multiple curl +commands joined by a blank line, in call order. + +`matches:` defaults to `nil`, same as `assertInlineSnapshot` itself, so a +first pass can omit it and let the library auto-write the recorded literal +into the call site on the first (intentionally failing) run — +`syntaxDescriptor: .init(trailingClosureOffset: 1)` is required for that +rewrite to target the right trailing closure, since `matches:` is the +*second* trailing closure here (`operation` is the first, unlabeled one). + +### `HTTPStubTrait` + +Usable at both `@Test` and `@Suite` level. Suite-level stubs are prepended; +a `@Test`-level trait on top appends its own stubs to whatever the suite +already queued, preserving order. Defined in the same file as +`HTTPTransportStub` so it can reach its `fileprivate remainingStubs`. + +```swift +public struct HTTPStubTrait: TestTrait, SuiteTrait, TestScoping { + let stubs: [HTTPStub] + + public func provideScope( + for test: Test, testCase: Test.Case?, performing function: () async throws -> Void + ) async throws { + let outerStubs = await HTTPTransportStub._current?.remainingStubs ?? [] + let transport = HTTPTransportStub(stubs: outerStubs + stubs) + try await HTTPTransportStub.$_current.withValue(transport) { + try await function() + await transport.assertAllConsumed() + } + } +} + +public func http(stubs: [HTTPStub]) -> HTTPStubTrait { .init(stubs: stubs) } +``` + +`TestScoping.provideScope` binds the TaskLocal for the duration of the test +body — isolated per task tree, so parallel Swift Testing runs never share +stub state across tests. + +## Data flow + +1. Test declares `@Test(.http(stubs: [...]))`. +2. Trait's `provideScope` builds an `HTTPTransportStub` from the merged + (suite + test) stub list and binds it as the TaskLocal for the test body. +3. Test body reads `HTTPTransportStub.current` and passes it explicitly to + the client under test (constructor injection — matches how `HTTPTransport` + is already wired into clients elsewhere in the codebase). +4. Each `send`/`stream` call the client makes is recorded, pops the next + stub, checks method + full URL, and returns the stubbed response. +5. Optionally, the test wraps a call in `assertHTTPRequests { ... } matches: + { ... }` to assert the curl rendering of whatever requests fired during + that call. +6. At scope exit, any unconsumed stub fails the test. + +## Error handling + +Every failure path uses `Issue.record` to fail the test — this is the real +signal, independent of whatever the thrown `HTTPError` triggers in the code +under test's own error handling. A test can't pass by accident just because +the client under test happens to swallow the thrown error. + +## Example usage + +```swift +@Test(.http(stubs: [ + .post("https://example.com/auth/v1/otp", status: 200) { + .string(#"{"message_id":"123"}"#) + } +])) +func signInWithOTP() async throws { + let client = MyClient(transport: HTTPTransportStub.current) + try await assertHTTPRequests { + try await client.signInWithOTP(email: "a@b.com") + } matches: { + """ + curl 'https://example.com/auth/v1/otp' \ + --header 'Content-Type: application/json' + """ + } +} +``` + +## Testing plan + +- Unit-test `HTTPTransportStub`'s match/consume/leftover logic directly, + without going through the trait. +- Unit-test `curlCommand(for:)` directly against representative `HTTPRequest` + values (headers, query, body variants). +- A thin trait-wiring test verifying `.http(stubs:)` actually binds the + TaskLocal and that mismatch/leftover/out-of-scope paths record issues (via + `withKnownIssue`). +- A test verifying `assertHTTPRequests` correctly slices only the requests + made during its own `operation()` closure — not ones made before it, and + not ones made by a second `assertHTTPRequests` call later in the same test. +- No integration with `Replay`, `Mocker`, or the app-level `TestHelpers` + target.