From d5138f85a33d23701bcc0c813090b760aa426d2c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 13:49:37 -0300 Subject: [PATCH 01/13] docs(functions): add Functions-to-HTTPRuntime migration design spec --- ...-functions-httpruntime-migration-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md 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..89ff55eac --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -0,0 +1,104 @@ +# 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. + +## 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`: + +```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. + +### 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. + +```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` (from `HTTPRuntime`) never leaks to `FunctionsClient` callers — any transport-level failure inside `FetchHandlerTransport.send` or the streaming path's `URLSessionTransport.stream` call is caught and re-thrown as whatever `FunctionsError`/underlying error the current code already throws in that situation (i.e. the conversion layer absorbs `HTTPError`, it doesn't propagate it). + +### 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. + +## Error handling + +- Transport-level failures (network errors, decoding failures) from `FetchHandlerTransport.send` or `URLSessionTransport.stream` are caught at the `HTTPError.transport(any Error)` boundary and converted to whatever error `FunctionsClient` already surfaces for that failure mode today (this migration doesn't change what errors callers see, only what's happening internally to produce them). +- Non-2xx responses and the relay-error header check keep their exact current semantics, just reading from `HTTPResponse`/`HTTPResponseHead` instead of `Helpers.HTTPResponse`. From ffbf595f43ba4f2328553a8c4dbaa66c02841851 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 13:51:54 -0300 Subject: [PATCH 02/13] docs(functions): account for LoggerInterceptor behavior in migration spec --- ...-functions-httpruntime-migration-design.md | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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 index 89ff55eac..b31c590d7 100644 --- a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -25,13 +25,38 @@ Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/` ### Buffered path (`invoke`, `invoke(decode:)`, `invoke(decoder:)`) -A new private type, `FetchHandlerTransport`, adapts the stored `fetch: FetchHandler` closure to `HTTPRuntime.HTTPTransport`: +A new private type, `FetchHandlerTransport`, adapts the stored `fetch: FetchHandler` closure to `HTTPRuntime.HTTPTransport`. It also takes over `LoggerInterceptor`'s job directly — `Helpers.HTTPClient`'s generic interceptor chain is not carried over; Functions only ever has exactly one optional behavior here (logging), so it's folded straight into the adapter rather than kept as a pluggable chain: ```swift private struct FetchHandlerTransport: HTTPTransport { let fetch: FunctionsClient.FetchHandler + let logger: (any SupabaseLogger)? func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) -> HTTPResponse { + guard let logger else { + return try await performSend(request) + } + let id = UUID().uuidString + return try await SupabaseLoggerTaskLocal.$additionalContext.withValue(merging: ["requestID": .string(id)]) { + logger.verbose(""" + Request: \(request.method.rawValue) \(request.url.absoluteString.removingPercentEncoding ?? request.url.absoluteString) + Body: \(stringify(request.bodyData)) + """) + do { + let response = try await performSend(request) + logger.verbose(""" + Response: Status code: \(response.head.status) + Body: \(stringify(response.body)) + """) + return response + } catch { + logger.error("Response: Failure \(error)") + throw error + } + } + } + + private func performSend(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponse { let urlRequest = Self.makeURLRequest(request) let data: Data let response: URLResponse @@ -57,7 +82,9 @@ private struct FetchHandlerTransport: HTTPTransport { } ``` -`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. +`stringify(_:)` (JSON-pretty-print a `Data?` body for logging, falling back to raw UTF-8 or `""`/`""`) is a small, private, self-contained helper duplicated in `Functions` rather than reused from `Helpers` — `Helpers.stringify` is `internal` (not `package`), and it's a ~15-line pure function, not worth a cross-module accessibility change for. + +`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, logger: logger)`, 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. `FunctionsClient` needs to retain a reference to the `logger:` it was given (it currently only passes it to the `HTTPClient` initializer and doesn't store it) so `FetchHandlerTransport` can be constructed with it per-request. ### Streaming path (`_invokeWithStreamedResponse`) From 2a320c4bf18265b7f2601dda38ced87d3e6173ce Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 13:53:41 -0300 Subject: [PATCH 03/13] docs(functions): drop request/response logging from migration scope --- ...-functions-httpruntime-migration-design.md | 33 +++---------------- 1 file changed, 4 insertions(+), 29 deletions(-) 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 index b31c590d7..11f53f0d1 100644 --- a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -13,6 +13,8 @@ Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/` - 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. ## Current state (for reference) @@ -25,38 +27,13 @@ Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/` ### Buffered path (`invoke`, `invoke(decode:)`, `invoke(decoder:)`) -A new private type, `FetchHandlerTransport`, adapts the stored `fetch: FetchHandler` closure to `HTTPRuntime.HTTPTransport`. It also takes over `LoggerInterceptor`'s job directly — `Helpers.HTTPClient`'s generic interceptor chain is not carried over; Functions only ever has exactly one optional behavior here (logging), so it's folded straight into the adapter rather than kept as a pluggable chain: +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 - let logger: (any SupabaseLogger)? func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) -> HTTPResponse { - guard let logger else { - return try await performSend(request) - } - let id = UUID().uuidString - return try await SupabaseLoggerTaskLocal.$additionalContext.withValue(merging: ["requestID": .string(id)]) { - logger.verbose(""" - Request: \(request.method.rawValue) \(request.url.absoluteString.removingPercentEncoding ?? request.url.absoluteString) - Body: \(stringify(request.bodyData)) - """) - do { - let response = try await performSend(request) - logger.verbose(""" - Response: Status code: \(response.head.status) - Body: \(stringify(response.body)) - """) - return response - } catch { - logger.error("Response: Failure \(error)") - throw error - } - } - } - - private func performSend(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponse { let urlRequest = Self.makeURLRequest(request) let data: Data let response: URLResponse @@ -82,9 +59,7 @@ private struct FetchHandlerTransport: HTTPTransport { } ``` -`stringify(_:)` (JSON-pretty-print a `Data?` body for logging, falling back to raw UTF-8 or `""`/`""`) is a small, private, self-contained helper duplicated in `Functions` rather than reused from `Helpers` — `Helpers.stringify` is `internal` (not `package`), and it's a ~15-line pure function, not worth a cross-module accessibility change for. - -`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, logger: logger)`, 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. `FunctionsClient` needs to retain a reference to the `logger:` it was given (it currently only passes it to the `HTTPClient` initializer and doesn't store it) so `FetchHandlerTransport` can be constructed with it per-request. +`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. ### Streaming path (`_invokeWithStreamedResponse`) From 7d0ef511181f0c869f66f27042c50ea5eac4e8a5 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 13:55:19 -0300 Subject: [PATCH 04/13] docs(functions): fix HTTPError unwrapping and HTTPURLResponse synthesis in spec --- ...26-07-11-functions-httpruntime-migration-design.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index 11f53f0d1..506576b35 100644 --- a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -61,6 +61,8 @@ private struct FetchHandlerTransport: HTTPTransport { `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. @@ -77,7 +79,11 @@ let responseStream = try await transport.stream(request) ### Errors -`FunctionsError`'s two cases (`.relayError`, `.httpError(code:data:)`) are unchanged. `HTTPError` (from `HTTPRuntime`) never leaks to `FunctionsClient` callers — any transport-level failure inside `FetchHandlerTransport.send` or the streaming path's `URLSessionTransport.stream` call is caught and re-thrown as whatever `FunctionsError`/underlying error the current code already throws in that situation (i.e. the conversion layer absorbs `HTTPError`, it doesn't propagate it). +`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 @@ -102,5 +108,6 @@ No new tests are required by this migration beyond what's needed to keep the exi ## Error handling -- Transport-level failures (network errors, decoding failures) from `FetchHandlerTransport.send` or `URLSessionTransport.stream` are caught at the `HTTPError.transport(any Error)` boundary and converted to whatever error `FunctionsClient` already surfaces for that failure mode today (this migration doesn't change what errors callers see, only what's happening internally to produce them). +- 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). From a437ebd2ccbeca4377a95ee5abad797d8528c581 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 14:01:15 -0300 Subject: [PATCH 05/13] docs(functions): document test-file fixups for the HTTPTypes removal --- .../2026-07-11-functions-httpruntime-migration-design.md | 4 ++++ 1 file changed, 4 insertions(+) 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 index 506576b35..5cc2ff0ed 100644 --- a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -106,6 +106,10 @@ If the `HTTPRequest ↔ URLRequest` round-trip introduces any incidental differe 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). From 86759b67e06d6a664eeb2b03fee6bc0568178bac Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 14:51:27 -0300 Subject: [PATCH 06/13] docs(functions): drop streaming request timeout from migration scope --- .../specs/2026-07-11-functions-httpruntime-migration-design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 5cc2ff0ed..fcd9cf29d 100644 --- a/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md +++ b/docs/superpowers/specs/2026-07-11-functions-httpruntime-migration-design.md @@ -15,6 +15,7 @@ Migrate `Sources/Functions`'s internal HTTP plumbing from `Helpers.HTTPClient`/` - 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) @@ -65,7 +66,7 @@ private struct FetchHandlerTransport: HTTPTransport { ### 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. +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) From 8b4d03ad977b524f6f99490ca2e986b7fc859996 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 14:59:16 -0300 Subject: [PATCH 07/13] docs(functions): add Functions-to-HTTPRuntime migration implementation plan --- ...6-07-11-functions-httpruntime-migration.md | 1001 +++++++++++++++++ 1 file changed, 1001 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-functions-httpruntime-migration.md 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. From 15d565220e87e163de031e047ded4c55ffbe0f23 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 15:30:20 -0300 Subject: [PATCH 08/13] refactor(functions): migrate headers and buffered invoke path to HTTPRuntime Switches Functions off Helpers.HTTPClient/HTTPRequest/HTTPResponse and onto HTTPRuntime for headers (HTTPFields -> [String: String]) and the buffered invoke/invoke(decode:)/invoke(decoder:) path, via a private FetchHandlerTransport adapter wrapping the existing fetch: closure as an HTTPTransport. Also fixes client.functions.headers.dictionary in SupabaseClientTests (now already [String: String]) to keep the package building. Streaming (_invokeWithStreamedResponse) gets a one-line stopgap and is fully migrated in a follow-up. --- Package.swift | 2 +- Sources/Functions/FunctionsClient.swift | 169 +++++++++++------- Sources/Functions/Types.swift | 18 +- .../FunctionInvokeOptionsTests.swift | 14 +- .../FunctionsTests/FunctionsClientTests.swift | 9 +- Tests/SupabaseTests/SupabaseClientTests.swift | 2 +- 6 files changed, 128 insertions(+), 86 deletions(-) diff --git a/Package.swift b/Package.swift index b3fb2f916..f358ad3f3 100644 --- a/Package.swift +++ b/Package.swift @@ -107,8 +107,8 @@ let package = Package( name: "Functions", dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), - .product(name: "HTTPTypes", package: "swift-http-types"), "Helpers", + "HTTPRuntime", ] ), .testTarget( diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 606d311f1..9f906faa5 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,6 +1,6 @@ import ConcurrencyExtras public import Foundation -import HTTPTypes +import HTTPRuntime public import Helpers #if canImport(FoundationNetworking) @@ -61,14 +61,14 @@ 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 fetch: FetchHandler private let mutableState = LockIsolated(MutableState()) private let sessionConfiguration: URLSessionConfiguration - var headers: HTTPFields { + var headers: [String: String] { mutableState.headers } @@ -93,36 +93,8 @@ public final class FunctionsClient: Sendable { url: url, headers: headers, region: region, - logger: logger, - fetch: fetch, decoder: decoder, - sessionConfiguration: .default - ) - } - - 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(), - 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 + fetch: fetch ) } @@ -131,19 +103,24 @@ public final class FunctionsClient: Sendable { headers: [String: String], region: String?, decoder: JSONDecoder = JSONDecoder(), - http: any HTTPClientType, + fetch: @escaping FetchHandler, sessionConfiguration: URLSessionConfiguration = .default ) { self.url = url self.region = region self.decoder = decoder - self.http = http + self.fetch = fetch self.sessionConfiguration = sessionConfiguration mutableState.withValue { - $0.headers = HTTPFields(headers) - if $0.headers[.xClientInfo] == nil { - $0.headers[.xClientInfo] = "functions-swift/\(version)" + $0.headers = 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)" } } } @@ -168,7 +145,6 @@ public final class FunctionsClient: Sendable { url: url, headers: headers, region: region?.rawValue, - logger: logger, fetch: fetch, decoder: decoder ) @@ -179,9 +155,9 @@ public final class FunctionsClient: Sendable { 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 +175,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 +215,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) + let transport = FetchHandlerTransport(fetch: fetch) - 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 { + 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 + return (response.body, httpResponse) } /// Invokes a function and returns its response as a stream of raw `Data` chunks. @@ -276,7 +266,8 @@ public final class FunctionsClient: Sendable { let session = URLSession( configuration: sessionConfiguration, delegate: delegate, delegateQueue: nil) - let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest + let urlRequest = FetchHandlerTransport.makeURLRequest( + buildRequest(functionName: functionName, options: invokeOptions)) let task = session.dataTask(with: urlRequest) task.resume() @@ -292,25 +283,77 @@ public final class FunctionsClient: Sendable { } private func buildRequest(functionName: String, options: FunctionInvokeOptions) - -> Helpers.HTTPRequest + -> 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) + + return HTTPRuntime.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: HTTPRuntime.HTTPRequest, uploadProgress: ProgressHandler?) + async throws(HTTPRuntime.HTTPError) + -> HTTPRuntime.HTTPResponse + { + let urlRequest = Self.makeURLRequest(request) + let data: Data + let response: URLResponse + do { + (data, response) = try await fetch(urlRequest) + } catch { + throw HTTPRuntime.HTTPError.transport(error) + } + guard let http = response as? HTTPURLResponse else { + throw HTTPRuntime.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 HTTPRuntime.HTTPResponse( + head: HTTPResponseHead(status: http.statusCode, headers: headers), body: data) + } + + func stream(_ request: HTTPRuntime.HTTPRequest) async throws(HTTPRuntime.HTTPError) + -> HTTPResponseStream + { + fatalError( + "FetchHandlerTransport does not support streaming; use URLSessionTransport instead") + } + + static func makeURLRequest(_ request: HTTPRuntime.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 + } } } 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/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..1ea2d4752 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -1,5 +1,4 @@ import ConcurrencyExtras -import HTTPTypes import InlineSnapshotTesting import Mocker import TestHelpers @@ -51,8 +50,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 { @@ -326,10 +325,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 { 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) From 4068f147b661bb8bb62e34bac0b52e8fa43081b9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Sat, 11 Jul 2026 15:36:31 -0300 Subject: [PATCH 09/13] refactor(functions): migrate streaming path to URLSessionTransport --- Sources/Functions/FunctionsClient.swift | 80 +++++++------------------ 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 9f906faa5..107bc66f2 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -260,25 +260,35 @@ public final class FunctionsClient: Sendable { _ 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 request = buildRequest(functionName: functionName, options: invokeOptions) + let transport = URLSessionTransport(configuration: sessionConfiguration) - let urlRequest = FetchHandlerTransport.makeURLRequest( - buildRequest(functionName: functionName, options: invokeOptions)) + let (stream, continuation) = AsyncThrowingStream.makeStream() - let task = session.dataTask(with: urlRequest) - task.resume() + let task = Task { + do { + let responseStream = try await transport.stream(request) - continuation.onTermination = { _ in - task.cancel() + 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 + } - // Hold a strong reference to delegate until continuation terminates. - _ = delegate + for try await chunk in responseStream.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() } + return stream } @@ -356,47 +366,3 @@ public final class FunctionsClient: Sendable { } } } - -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) - } - } -} From fc81166fdd06a8bc6e5a890d662365abbe1e9ad0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 Jul 2026 05:59:13 -0300 Subject: [PATCH 10/13] refactor(functions): rework FunctionsClient config around FunctionsClientOptions + HTTPTransport Introduce FunctionsClientOptions and an injectable HTTPTransport as the primary construction path, moving the legacy fetch:-closure initializers to deprecated back-compat shims in Deprecated.swift. FetchHandlerTransport's stream() now delegates to a real URLSessionTransport instead of being unreachable. --- Sources/Functions/Deprecated.swift | 61 ++++++ Sources/Functions/FunctionsClient.swift | 200 +++++++----------- Sources/HTTPRuntime/HTTPResponse.swift | 14 ++ Sources/HTTPRuntime/HTTPTransport.swift | 10 +- Sources/HTTPRuntime/URLSessionTransport.swift | 52 ++--- 5 files changed, 182 insertions(+), 155 deletions(-) create mode 100644 Sources/Functions/Deprecated.swift diff --git a/Sources/Functions/Deprecated.swift b/Sources/Functions/Deprecated.swift new file mode 100644 index 000000000..282fdf775 --- /dev/null +++ b/Sources/Functions/Deprecated.swift @@ -0,0 +1,61 @@ +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 107bc66f2..bd4c39c6f 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,6 +1,6 @@ import ConcurrencyExtras public import Foundation -import HTTPRuntime +package import HTTPRuntime public import Helpers #if canImport(FoundationNetworking) @@ -9,6 +9,28 @@ public import Helpers let version = Helpers.version +public struct FunctionsClientOptions: Sendable { + public var headers: [String: String] + public var region: String? + public var logger: (any SupabaseLogger)? + public var decoder: JSONDecoder + public var session: URLSession + + 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. @@ -64,56 +86,38 @@ public final class FunctionsClient: Sendable { var headers: [String: String] = [:] } - private let fetch: FetchHandler private let mutableState = LockIsolated(MutableState()) - private let sessionConfiguration: URLSessionConfiguration + + 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 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, - decoder: decoder, - fetch: fetch + options: options, + transport: URLSessionTransport(session: options.session) ) } - init( + /// Internal initializer, used for testing and by SupabaseClient. + package init( url: URL, - headers: [String: String], - region: String?, - decoder: JSONDecoder = JSONDecoder(), - fetch: @escaping FetchHandler, - sessionConfiguration: URLSessionConfiguration = .default + options: FunctionsClientOptions, + transport: any HTTPTransport ) { self.url = url - self.region = region - self.decoder = decoder - self.fetch = fetch - self.sessionConfiguration = sessionConfiguration + self.region = options.region + self.decoder = options.decoder + self.transport = transport mutableState.withValue { - $0.headers = headers + $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 { @@ -125,31 +129,6 @@ public final class FunctionsClient: Sendable { } } - /// 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?) { @@ -217,7 +196,6 @@ public final class FunctionsClient: Sendable { invokeOptions: FunctionInvokeOptions ) async throws -> (data: Data, response: HTTPURLResponse) { let request = buildRequest(functionName: functionName, options: invokeOptions) - let transport = FetchHandlerTransport(fetch: fetch) let response: HTTPRuntime.HTTPResponse do { @@ -227,19 +205,20 @@ public final class FunctionsClient: Sendable { } guard - let httpResponse = HTTPURLResponse( - url: request.url, statusCode: response.head.status, httpVersion: nil, - headerFields: response.head.headers) + let httpResponse = response.head._underlyingHTTPResponse + ?? 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) - } + guard response.head.isSuccess else { + if response.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError + } - if response.head.header("x-relay-error") == "true" { - throw FunctionsError.relayError + throw FunctionsError.httpError(code: response.head.status, data: response.body) } return (response.body, httpResponse) @@ -259,42 +238,34 @@ public final class FunctionsClient: Sendable { public func _invokeWithStreamedResponse( _ functionName: String, options invokeOptions: FunctionInvokeOptions = .init() - ) -> AsyncThrowingStream { + ) async throws -> AsyncThrowingStream { let request = buildRequest(functionName: functionName, options: invokeOptions) - let transport = URLSessionTransport(configuration: sessionConfiguration) + let response: HTTPResponseStream - let (stream, continuation) = AsyncThrowingStream.makeStream() + do { + response = try await transport.stream(request) + } catch HTTPRuntime.HTTPError.transport(let underlying) { + throw underlying + } catch { + throw error + } - 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 HTTPRuntime.HTTPError.transport(let underlying) { - continuation.finish(throwing: underlying) - } catch { - continuation.finish(throwing: error) + guard response.head.isSuccess else { + if response.head.header("x-relay-error") == "true" { + throw FunctionsError.relayError } - } - continuation.onTermination = { _ in task.cancel() } + let data = try await response.body.collect() + throw FunctionsError.httpError(code: response.head.status, data: data) + } - return stream + return response.body } - private func buildRequest(functionName: String, options: FunctionInvokeOptions) - -> HTTPRuntime.HTTPRequest - { + private func buildRequest( + functionName: String, + options: FunctionInvokeOptions + ) -> HTTPRuntime.HTTPRequest { var query = options.query var requestHeaders = mutableState.headers.merging(options.headers) { $1 } @@ -313,11 +284,12 @@ public final class FunctionsClient: Sendable { ) } - /// 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 { + /// 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?) @@ -332,36 +304,24 @@ public final class FunctionsClient: Sendable { } catch { throw HTTPRuntime.HTTPError.transport(error) } - guard let http = response as? HTTPURLResponse else { - throw HTTPRuntime.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 HTTPRuntime.HTTPResponse( - head: HTTPResponseHead(status: http.statusCode, headers: headers), body: data) + head: URLSessionTransport.makeHead(response), body: data) } func stream(_ request: HTTPRuntime.HTTPRequest) async throws(HTTPRuntime.HTTPError) -> HTTPResponseStream { - fatalError( - "FetchHandlerTransport does not support streaming; use URLSessionTransport instead") + do { + return try await URLSessionTransport().stream(request) + } catch { + throw HTTPRuntime.HTTPError.transport(error) + } } static func makeURLRequest(_ request: HTTPRuntime.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 - } + var urlRequest = URLSessionTransport.makeURLRequest(request) + urlRequest.timeoutInterval = FunctionsClient.requestIdleTimeout return urlRequest } } diff --git a/Sources/HTTPRuntime/HTTPResponse.swift b/Sources/HTTPRuntime/HTTPResponse.swift index 788ed2056..237265061 100644 --- a/Sources/HTTPRuntime/HTTPResponse.swift +++ b/Sources/HTTPRuntime/HTTPResponse.swift @@ -15,6 +15,9 @@ package struct HTTPResponseHead: Sendable { package let status: Int package let headers: [String: String] + /// The underlying `HTTPURLResponse` that was used to create this response, if any. + package var _underlyingHTTPResponse: HTTPURLResponse? + package init(status: Int, headers: [String: String]) { self.status = status self.headers = headers @@ -27,6 +30,7 @@ package struct HTTPResponseHead: Sendable { return headers.first { $0.key.lowercased() == lowered }?.value } + /// Returns `true` if the status code is in the 2xx range. package var isSuccess: Bool { (200..<300).contains(status) } } @@ -52,3 +56,13 @@ package struct HTTPResponseStream: Sendable { self.body = body } } + +extension AsyncThrowingStream where Element == Data, Failure == any Error { + package func collect() async throws -> Data { + var result = Data() + for try await chunk in self { + result.append(chunk) + } + return result + } +} diff --git a/Sources/HTTPRuntime/HTTPTransport.swift b/Sources/HTTPRuntime/HTTPTransport.swift index e60db5b03..c4e2f7225 100644 --- a/Sources/HTTPRuntime/HTTPTransport.swift +++ b/Sources/HTTPRuntime/HTTPTransport.swift @@ -4,21 +4,23 @@ // // Created by Guilherme Souza on 08/07/26. // + +import Foundation + /// The abstraction generated clients depend on. Kept deliberately small so the /// generated code never touches `URLSession` directly and so tests can inject a /// mock transport. package protocol HTTPTransport: Sendable { /// Buffered request/response. - func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws(HTTPError) - -> HTTPResponse + func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) async throws -> HTTPResponse /// Streaming response: head first, body as an async sequence of chunks. /// Used for large downloads and event streams. - func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream + func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream } extension HTTPTransport { - package func send(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponse { + package func send(_ request: HTTPRequest) async throws -> HTTPResponse { try await send(request, uploadProgress: nil) } } diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 4d7d111ca..443f554ec 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -27,36 +27,35 @@ package import Foundation package struct URLSessionTransport: HTTPTransport { private let session: URLSession - package init(configuration: URLSessionConfiguration = .default) { + package init( + configuration: URLSessionConfiguration = .default + ) { self.session = URLSession(configuration: configuration) } - package init(session: URLSession) { + package init( + session: URLSession + ) { self.session = session } package func send(_ request: HTTPRequest, uploadProgress: ProgressHandler?) - async throws(HTTPError) -> HTTPResponse + async throws -> HTTPResponse { let urlRequest = Self.makeURLRequest(request) let delegate = uploadProgress.map { ProgressDelegate(onProgress: $0) } - let data: Data - let response: URLResponse - do { + let (data, response) = switch request.body { case nil: - (data, response) = try await session.data(for: urlRequest, delegate: delegate) + try await session.data(for: urlRequest, delegate: delegate) case .data(let payload): - (data, response) = try await session.upload( + try await session.upload( for: urlRequest, from: payload, delegate: delegate) case .file(let fileURL): - (data, response) = try await session.upload( + try await session.upload( for: urlRequest, fromFile: fileURL, delegate: delegate) } - } catch { - throw HTTPError.transport(error) - } return HTTPResponse(head: Self.makeHead(response), body: data) } @@ -64,15 +63,10 @@ package struct URLSessionTransport: HTTPTransport { // swift-corelibs-foundation has no async byte-streaming API // (`bytes(for:)`/`AsyncBytes`), so on Linux the response is buffered in // full and delivered as a single chunk instead of streamed incrementally. - package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { let urlRequest = Self.makeURLRequest(request) - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: urlRequest) - } catch { - throw HTTPError.transport(error) - } + let (data, response) = try await session.data(for: urlRequest) + let body = AsyncThrowingStream { continuation in continuation.yield(data) continuation.finish() @@ -80,15 +74,9 @@ package struct URLSessionTransport: HTTPTransport { return HTTPResponseStream(head: Self.makeHead(response), body: body) } #else - package func stream(_ request: HTTPRequest) async throws(HTTPError) -> HTTPResponseStream { + package func stream(_ request: HTTPRequest) async throws -> HTTPResponseStream { let urlRequest = Self.makeURLRequest(request) - let bytes: URLSession.AsyncBytes - let response: URLResponse - do { - (bytes, response) = try await session.bytes(for: urlRequest) - } catch { - throw HTTPError.transport(error) - } + let (bytes, response) = try await session.bytes(for: urlRequest) let body = AsyncThrowingStream { continuation in let task = Task { @@ -118,7 +106,7 @@ package struct URLSessionTransport: HTTPTransport { // MARK: - Helpers - private static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { + package static func makeURLRequest(_ request: HTTPRequest) -> URLRequest { var urlRequest = URLRequest(url: request.url) urlRequest.httpMethod = request.method.rawValue for (name, value) in request.headers { @@ -130,7 +118,7 @@ package struct URLSessionTransport: HTTPTransport { return urlRequest } - private static func makeHead(_ response: URLResponse) -> HTTPResponseHead { + package static func makeHead(_ response: URLResponse) -> HTTPResponseHead { guard let http = response as? HTTPURLResponse else { return HTTPResponseHead(status: 0, headers: [:]) } @@ -140,7 +128,9 @@ package struct URLSessionTransport: HTTPTransport { headers[key] = value } } - return HTTPResponseHead(status: http.statusCode, headers: headers) + var head = HTTPResponseHead(status: http.statusCode, headers: headers) + head._underlyingHTTPResponse = http + return head } } From 11aa9c699ea5f26e7a67e4c9c2bc1fc5fe949932 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 Jul 2026 06:22:25 -0300 Subject: [PATCH 11/13] docs(functions): document FunctionsClientOptions and init(url:options:) test(functions): fix FunctionsClientTests for new options/transport API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds missing DocC comments on FunctionsClientOptions and the primary init(url:options:), and updates the Topics outline to reference the current public API instead of the initializer now in Deprecated.swift. Test fixes: drop the removed sessionConfiguration: argument in favor of the transport-based init, add missing try await on _invokeWithStreamedResponse call sites, and fix two relay-error tests that mocked a 2xx status — relay-error detection only runs on non-2xx responses by design, so those mocks now use a non-2xx status code. --- Sources/Functions/FunctionsClient.swift | 20 +++++++++++++- .../FunctionsTests/FunctionsClientTests.swift | 26 ++++++++----------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index bd4c39c6f..3bc6df48a 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -9,13 +9,26 @@ 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, @@ -49,7 +62,8 @@ public struct FunctionsClientOptions: Sendable { /// ## Topics /// /// ### Creating a Client -/// - ``init(url:headers:region:logger:fetch:decoder:)`` +/// - ``init(url:options:)`` +/// - ``FunctionsClientOptions`` /// - ``FetchHandler`` /// /// ### Invoking Functions @@ -94,6 +108,10 @@ public final class FunctionsClient: Sendable { mutableState.headers } + /// Creates a new Functions client. + /// - Parameters: + /// - url: The base URL of the Functions endpoint. + /// - options: Options for configuring the client. public convenience init( url: URL, options: FunctionsClientOptions = FunctionsClientOptions() diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 1ea2d4752..baebb621f 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -1,4 +1,5 @@ import ConcurrencyExtras +import HTTPRuntime import InlineSnapshotTesting import Mocker import TestHelpers @@ -27,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() { @@ -297,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" @@ -348,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") @@ -372,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") } @@ -386,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" @@ -403,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") } From 432072f70a7060514bfd93468afb320e83991cac Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 Jul 2026 06:24:03 -0300 Subject: [PATCH 12/13] revert(functions): restore FetchHandlerTransport plain-throws + reportIssue Un-reverts the FetchHandlerTransport change from the docs pass: send/stream use plain throws (no HTTPError.transport re-wrapping), and send reports an issue when uploadProgress is set, since the fetch: closure has no way to honor it. --- Sources/Functions/FunctionsClient.swift | 32 ++++++++++--------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 3bc6df48a..779bd0a64 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -2,6 +2,7 @@ import ConcurrencyExtras public import Foundation package import HTTPRuntime public import Helpers +import IssueReporting #if canImport(FoundationNetworking) public import FoundationNetworking @@ -310,31 +311,24 @@ public final class FunctionsClient: Sendable { struct FetchHandlerTransport: HTTPTransport { let fetch: FunctionsClient.FetchHandler - func send(_ request: HTTPRuntime.HTTPRequest, uploadProgress: ProgressHandler?) - async throws(HTTPRuntime.HTTPError) - -> HTTPRuntime.HTTPResponse - { - let urlRequest = Self.makeURLRequest(request) - let data: Data - let response: URLResponse - do { - (data, response) = try await fetch(urlRequest) - } catch { - throw HTTPRuntime.HTTPError.transport(error) + 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 = Self.makeURLRequest(request) + let (data, response) = try await fetch(urlRequest) return HTTPRuntime.HTTPResponse( head: URLSessionTransport.makeHead(response), body: data) } - func stream(_ request: HTTPRuntime.HTTPRequest) async throws(HTTPRuntime.HTTPError) - -> HTTPResponseStream - { - do { - return try await URLSessionTransport().stream(request) - } catch { - throw HTTPRuntime.HTTPError.transport(error) - } + func stream(_ request: HTTPRuntime.HTTPRequest) async throws -> HTTPResponseStream { + try await URLSessionTransport().stream(request) } static func makeURLRequest(_ request: HTTPRuntime.HTTPRequest) -> URLRequest { From a402e8a6d7d1491362ebea955f76a5b4cd21d854 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 13 Jul 2026 06:34:49 -0300 Subject: [PATCH 13/13] fix(functions): restore request idle timeout and stream error unwrapping Whole-branch review turned up regressions from the HTTPRuntime migration: - HTTPRequest gains a generic `timeout` field, applied by URLSessionTransport.makeURLRequest. FunctionsClient.buildRequest sets it to requestIdleTimeout on every request, so the 150s gateway timeout applies uniformly again (previously only the deprecated FetchHandlerTransport path set it, so the new init(url:options:) path silently fell back to URLSession's default ~60s timeout). This also lets FetchHandlerTransport drop its duplicate makeURLRequest override. - _invokeWithStreamedResponse now wraps the returned body stream so mid-stream transport failures unwrap HTTPError.transport to the underlying error, matching the buffered rawInvoke path instead of leaking a package-internal error type to callers. - Deprecated.swift gets the standard file header used by every sibling file in Sources/Functions and Sources/HTTPRuntime. - Fixes a stale doc comment claiming the package init(url:options:transport:) is used by SupabaseClient (it still uses the deprecated initializer). --- Sources/Functions/Deprecated.swift | 6 ++++ Sources/Functions/FunctionsClient.swift | 30 ++++++++++++------- Sources/HTTPRuntime/HTTPRequest.swift | 5 +++- Sources/HTTPRuntime/URLSessionTransport.swift | 3 ++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Sources/Functions/Deprecated.swift b/Sources/Functions/Deprecated.swift index 282fdf775..815b54473 100644 --- a/Sources/Functions/Deprecated.swift +++ b/Sources/Functions/Deprecated.swift @@ -1,3 +1,9 @@ +// +// Deprecated.swift +// Functions +// +// Created by Guilherme Souza on 13/07/26. +// public import Foundation public import Helpers diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index 779bd0a64..df5c07f88 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -124,7 +124,7 @@ public final class FunctionsClient: Sendable { ) } - /// Internal initializer, used for testing and by SupabaseClient. + /// Internal initializer for injecting a custom transport, used for testing. package init( url: URL, options: FunctionsClientOptions, @@ -278,7 +278,22 @@ public final class FunctionsClient: Sendable { throw FunctionsError.httpError(code: response.head.status, data: data) } - return response.body + 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( @@ -299,7 +314,8 @@ public final class FunctionsClient: Sendable { method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, url: requestURL, headers: requestHeaders, - body: options.body.map { HTTPBody.data($0) } + body: options.body.map { HTTPBody.data($0) }, + timeout: Self.requestIdleTimeout ) } @@ -320,7 +336,7 @@ public final class FunctionsClient: Sendable { "Upload progress is not supported with a custom fetch handler." ) } - let urlRequest = Self.makeURLRequest(request) + let urlRequest = URLSessionTransport.makeURLRequest(request) let (data, response) = try await fetch(urlRequest) return HTTPRuntime.HTTPResponse( @@ -330,11 +346,5 @@ public final class FunctionsClient: Sendable { func stream(_ request: HTTPRuntime.HTTPRequest) async throws -> HTTPResponseStream { try await URLSessionTransport().stream(request) } - - static func makeURLRequest(_ request: HTTPRuntime.HTTPRequest) -> URLRequest { - var urlRequest = URLSessionTransport.makeURLRequest(request) - urlRequest.timeoutInterval = FunctionsClient.requestIdleTimeout - return urlRequest - } } } diff --git a/Sources/HTTPRuntime/HTTPRequest.swift b/Sources/HTTPRuntime/HTTPRequest.swift index 84ecf7f04..0954e4e1c 100644 --- a/Sources/HTTPRuntime/HTTPRequest.swift +++ b/Sources/HTTPRuntime/HTTPRequest.swift @@ -24,17 +24,20 @@ package struct HTTPRequest: Sendable { package var url: URL package var headers: [String: String] package var body: HTTPBody? + package var timeout: TimeInterval? package init( method: HTTPMethod, url: URL, headers: [String: String] = [:], - body: HTTPBody? = nil + body: HTTPBody? = nil, + timeout: TimeInterval? = nil ) { self.method = method self.url = url self.headers = headers self.body = body + self.timeout = timeout } } diff --git a/Sources/HTTPRuntime/URLSessionTransport.swift b/Sources/HTTPRuntime/URLSessionTransport.swift index 443f554ec..e6db1e07b 100644 --- a/Sources/HTTPRuntime/URLSessionTransport.swift +++ b/Sources/HTTPRuntime/URLSessionTransport.swift @@ -115,6 +115,9 @@ package struct URLSessionTransport: HTTPTransport { if case .data(let payload) = request.body { urlRequest.httpBody = payload } + if let timeout = request.timeout { + urlRequest.timeoutInterval = timeout + } return urlRequest }