Skip to content

Commit 6f97cf2

Browse files
grdsdevclaude
andcommitted
feat(helpers): add _HTTPClient (#942)
* feat(helpers): add _HTTPClient with RequestBody and separate query/body params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(helpers): add TokenProvider support to _HTTPClient Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(helpers): stream UInt8 bytes instead of single-byte Data chunks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(helpers): add FoundationNetworking import for Linux and Sendable conformance to RequestBody Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(helpers): guard fetchStream behind canImport(Darwin) — URLSession.bytes unavailable on Linux Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(helpers): use @autoclosure @sendable for RequestBody to satisfy Sendable without @unchecked Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): resolve Sendable and actor-isolation errors in PostgREST and Storage tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 652cd26 commit 6f97cf2

10 files changed

Lines changed: 452 additions & 107 deletions

Sources/Helpers/_HTTPClient.swift

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
//
2+
// _HTTPClient.swift
3+
// Supabase
4+
//
5+
// Created by Guilherme Souza on 12/03/26.
6+
//
7+
8+
import Foundation
9+
10+
#if canImport(FoundationNetworking)
11+
import FoundationNetworking
12+
#endif
13+
14+
/// HTTP methods supported by ``_HTTPClient``.
15+
enum HTTPMethod: String {
16+
case get = "GET"
17+
case head = "HEAD"
18+
case post = "POST"
19+
case patch = "PATCH"
20+
case put = "PUT"
21+
case delete = "DELETE"
22+
}
23+
24+
enum RequestBody {
25+
case encodable(
26+
@autoclosure @Sendable () -> any Encodable,
27+
encoder: JSONEncoder = JSONEncoder.supabase()
28+
)
29+
case json(@autoclosure @Sendable () -> [String: Any])
30+
case data(Data)
31+
}
32+
33+
typealias TokenProvider = @Sendable () async throws -> String?
34+
35+
/// HTTP client for making all Supabase API requests.
36+
///
37+
/// Builds `URLRequest` values from a base `host` URL and dispatches them via `URLSession`.
38+
/// Responses are validated for 2xx status codes before being returned.
39+
final class _HTTPClient: Sendable {
40+
41+
/// The base URL for the API. This will be used as the base for all requests made by this client.
42+
let host: URL
43+
44+
/// The URLSession used to perform network requests.
45+
let session: URLSession
46+
47+
let tokenProvider: TokenProvider?
48+
49+
/// The JSONDecoder used to decode responses from the server.
50+
let jsonDecoder = JSONDecoder.supabase()
51+
52+
init(
53+
host: URL, session: URLSession = URLSession(configuration: .default),
54+
tokenProvider: TokenProvider? = nil
55+
) {
56+
self.host = host
57+
self.session = session
58+
self.tokenProvider = tokenProvider
59+
}
60+
61+
/// Performs a request relative to ``host``, decoding the response body as `T`.
62+
///
63+
/// Uses ``jsonDecoder`` (a `JSONDecoder` with Supabase defaults) to decode the response.
64+
/// If you need custom decoding, use ``fetchData(_:_:query:body:headers:)`` and decode at the call site.
65+
func fetch<T: Decodable>(
66+
_ method: HTTPMethod, _ path: String, query: [String: String]? = nil,
67+
body: RequestBody? = nil, headers: [String: String]? = nil
68+
) async throws -> (T, HTTPURLResponse) {
69+
let request = try await createRequest(method, path, query: query, body: body, headers: headers)
70+
return try await performFetch(request: request)
71+
}
72+
73+
/// Performs a request to an absolute `url`, decoding the response body as `T`.
74+
///
75+
/// Uses ``jsonDecoder`` (a `JSONDecoder` with Supabase defaults) to decode the response.
76+
/// If you need custom decoding, use ``fetchData(_:url:query:body:headers:)`` and decode at the call site.
77+
func fetch<T: Decodable>(
78+
_ method: HTTPMethod, url: URL, query: [String: String]? = nil, body: RequestBody? = nil,
79+
headers: [String: String]? = nil
80+
) async throws -> (T, HTTPURLResponse) {
81+
let request = try await createRequest(
82+
method, url: url, query: query, body: body, headers: headers)
83+
return try await performFetch(request: request)
84+
}
85+
86+
private func performFetch<T: Decodable>(
87+
request: URLRequest
88+
) async throws -> (T, HTTPURLResponse) {
89+
let (data, response) = try await performFetch(request: request)
90+
91+
do {
92+
let value = try jsonDecoder.decode(T.self, from: data)
93+
return (value, response)
94+
} catch {
95+
throw HTTPClientError.decodingError(response, detail: error.localizedDescription)
96+
}
97+
}
98+
99+
#if canImport(Darwin)
100+
/// Streams the response body byte-by-byte via an `AsyncThrowingStream`.
101+
///
102+
/// Cancelling the stream cancels the underlying `URLSession` task. Non-2xx responses
103+
/// buffer the full error body and throw ``HTTPClientError/responseError(_:data:)``.
104+
///
105+
/// - Note: Only available on Apple platforms. `URLSession.bytes(for:)` is not available on Linux.
106+
@available(macOS 12.0, *)
107+
func fetchStream(
108+
_ method: HTTPMethod, _ path: String, query: [String: String]? = nil,
109+
body: RequestBody? = nil, headers: [String: String]? = nil
110+
) -> AsyncThrowingStream<UInt8, any Error> {
111+
performFetchStream(
112+
method,
113+
requestBuilder: { [self] in
114+
try await self.createRequest(method, path, query: query, body: body, headers: headers)
115+
}
116+
)
117+
}
118+
119+
/// Streams the response body from an absolute `url` byte-by-byte.
120+
///
121+
/// - Note: Only available on Apple platforms. `URLSession.bytes(for:)` is not available on Linux.
122+
@available(macOS 12.0, *)
123+
func fetchStream(
124+
_ method: HTTPMethod, url: URL, query: [String: String]? = nil, body: RequestBody? = nil,
125+
headers: [String: String]? = nil
126+
) -> AsyncThrowingStream<UInt8, any Error> {
127+
performFetchStream(
128+
method,
129+
requestBuilder: { [self] in
130+
try await self.createRequest(method, url: url, query: query, body: body, headers: headers)
131+
}
132+
)
133+
}
134+
135+
@available(macOS 12.0, *)
136+
private func performFetchStream(
137+
_ method: HTTPMethod, requestBuilder: @escaping @Sendable () async throws -> URLRequest
138+
) -> AsyncThrowingStream<UInt8, any Error> {
139+
AsyncThrowingStream { continuation in
140+
let task = Task {
141+
do {
142+
let request = try await requestBuilder()
143+
144+
let (bytes, response) = try await session.bytes(for: request)
145+
let httpResponse = try validateResponse(response)
146+
147+
guard (200..<300).contains(httpResponse.statusCode) else {
148+
var errorData = Data()
149+
for try await byte in bytes {
150+
errorData.append(byte)
151+
}
152+
// validateResponse will throw the appropriate error
153+
_ = try validateResponse(response, data: errorData)
154+
return // This line will never be reached, but satisfies the compiler
155+
}
156+
157+
for try await byte in bytes {
158+
continuation.yield(byte)
159+
}
160+
161+
continuation.finish()
162+
}
163+
}
164+
165+
continuation.onTermination = { _ in
166+
task.cancel()
167+
}
168+
}
169+
}
170+
#endif
171+
172+
/// Performs a request relative to ``host``, returning the raw response body.
173+
func fetchData(
174+
_ method: HTTPMethod, _ path: String, query: [String: String]? = nil,
175+
body: RequestBody? = nil, headers: [String: String]? = nil
176+
) async throws -> (Data, HTTPURLResponse) {
177+
let request = try await createRequest(method, path, query: query, body: body, headers: headers)
178+
return try await performFetch(request: request)
179+
}
180+
181+
/// Performs a request to an absolute `url`, returning the raw response body.
182+
func fetchData(
183+
_ method: HTTPMethod, url: URL, query: [String: String]? = nil, body: RequestBody? = nil,
184+
headers: [String: String]? = nil
185+
) async throws -> (Data, HTTPURLResponse) {
186+
let request = try await createRequest(
187+
method, url: url, query: query, body: body, headers: headers)
188+
return try await performFetch(request: request)
189+
}
190+
191+
private func performFetch(
192+
request: URLRequest
193+
) async throws -> (Data, HTTPURLResponse) {
194+
let (data, response) = try await session.data(for: request)
195+
let httpResponse = try validateResponse(response, data: data)
196+
return (data, httpResponse)
197+
}
198+
199+
/// Builds a `URLRequest` by appending `path` to ``host``.
200+
///
201+
/// - Parameters:
202+
/// - method: The HTTP method.
203+
/// - path: The path component to append to ``host``.
204+
/// - query: Optional query parameters. Always encoded as URL query items.
205+
/// - body: Optional request body. Encoded according to the ``RequestBody`` case.
206+
/// - headers: Optional additional headers. `Accept: application/json` is added by default.
207+
func createRequest(
208+
_ method: HTTPMethod, _ path: String, query: [String: String]? = nil,
209+
body: RequestBody? = nil, headers: [String: String]? = nil
210+
) async throws -> URLRequest {
211+
var urlComponents = URLComponents(url: host, resolvingAgainstBaseURL: true)
212+
urlComponents?.path = path
213+
214+
return try await createRequest(
215+
method, urlComponents: urlComponents, query: query, body: body, headers: headers)
216+
}
217+
218+
/// Builds a `URLRequest` from an absolute `url`.
219+
func createRequest(
220+
_ method: HTTPMethod, url: URL, query: [String: String]? = nil, body: RequestBody? = nil,
221+
headers: [String: String]? = nil
222+
) async throws -> URLRequest {
223+
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)
224+
225+
return try await createRequest(
226+
method, urlComponents: urlComponents, query: query, body: body, headers: headers)
227+
}
228+
229+
private func createRequest(
230+
_ method: HTTPMethod, urlComponents: URLComponents?, query: [String: String]? = nil,
231+
body: RequestBody? = nil, headers: [String: String]? = nil
232+
) async throws -> URLRequest {
233+
var urlComponents = urlComponents
234+
235+
if let query {
236+
var queryItems = urlComponents?.queryItems ?? []
237+
for (key, value) in query {
238+
queryItems.append(URLQueryItem(name: key, value: value))
239+
}
240+
urlComponents?.queryItems = queryItems
241+
}
242+
243+
guard let url = urlComponents?.url else {
244+
throw URLError(.badURL)
245+
}
246+
247+
var request = URLRequest(url: url)
248+
request.httpMethod = method.rawValue
249+
250+
if let headers {
251+
for (key, value) in headers {
252+
request.setValue(value, forHTTPHeaderField: key)
253+
}
254+
}
255+
256+
if let tokenProvider,
257+
request.value(forHTTPHeaderField: "Authorization") == nil,
258+
let token = try await tokenProvider()
259+
{
260+
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
261+
}
262+
263+
if request.value(forHTTPHeaderField: "Accept") == nil {
264+
request.setValue("application/json", forHTTPHeaderField: "Accept")
265+
}
266+
267+
if let body {
268+
switch body {
269+
case .encodable(let value, let encoder):
270+
request.httpBody = try encoder.encode(value())
271+
case .json(let dict):
272+
request.httpBody = try JSONSerialization.data(withJSONObject: dict())
273+
case .data(let data):
274+
request.httpBody = data
275+
}
276+
if request.value(forHTTPHeaderField: "Content-Type") == nil {
277+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
278+
}
279+
}
280+
281+
return request
282+
}
283+
284+
/// Casts `response` to `HTTPURLResponse` and throws if the status code is outside 2xx.
285+
///
286+
/// - Parameters:
287+
/// - response: The raw `URLResponse` to validate.
288+
/// - data: When provided, included in ``HTTPClientError/responseError(_:data:)`` on failure.
289+
@discardableResult
290+
func validateResponse(_ response: URLResponse, data: Data? = nil) throws -> HTTPURLResponse {
291+
guard let response = response as? HTTPURLResponse else {
292+
throw HTTPClientError.unexpectedError(
293+
"Invalid response from server: \(response)"
294+
)
295+
}
296+
297+
if let data, !(200..<300).contains(response.statusCode) {
298+
throw HTTPClientError.responseError(response, data: data)
299+
}
300+
301+
return response
302+
}
303+
}
304+
305+
/// Errors thrown by ``_HTTPClient``.
306+
enum HTTPClientError: Error {
307+
/// The server returned a non-2xx status code. The raw response body is included in `data`.
308+
case responseError(HTTPURLResponse, data: Data)
309+
/// The response body could not be decoded into the expected type.
310+
case decodingError(HTTPURLResponse, detail: String)
311+
/// An unexpected error occurred (e.g. the response was not an `HTTPURLResponse`).
312+
case unexpectedError(String)
313+
}

Sources/PostgREST/PostgrestBuilder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ public class PostgrestBuilder: @unchecked Sendable {
113113

114114
private func execute<T>(
115115
options: FetchOptions,
116-
decode: @Sendable (Data) throws -> T
116+
decode: (Data) throws -> T
117117
) async throws -> PostgrestResponse<T> {
118118
let (baseRequest, retryEnabled) = mutableState.withValue { ($0.request, $0.retryEnabled) }
119119
var request = baseRequest

Sources/PostgREST/Types.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public struct PostgrestResponse<T> {
3939
}
4040
}
4141

42+
extension PostgrestResponse: Sendable where T: Sendable {}
43+
4244
/// Returns count as part of the response when specified.
4345
public enum CountOption: String, Sendable {
4446
/// Exact but slow count algorithm. Performs a `COUNT(*)` under the hood.

Tests/PostgRESTTests/PostgresQueryTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import XCTest
1616
#endif
1717

1818
class PostgrestQueryTests: XCTestCase {
19-
let url = URL(string: "http://localhost:54321/rest/v1")!
19+
static let url = URL(string: "http://localhost:54321/rest/v1")!
2020

2121
let sessionConfiguration: URLSessionConfiguration = {
2222
let configuration = URLSessionConfiguration.default
@@ -27,7 +27,7 @@ class PostgrestQueryTests: XCTestCase {
2727
lazy var session = URLSession(configuration: sessionConfiguration)
2828

2929
lazy var sut = PostgrestClient(
30-
url: url,
30+
url: Self.url,
3131
headers: [
3232
"apikey":
3333
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"

0 commit comments

Comments
 (0)