• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / supabase-swift / 23862965010

01 Apr 2026 05:55PM UTC coverage: 78.695%. First build
23862965010

Pull #942

github

web-flow
Merge 3e80db5ab into 8d722ca52
Pull Request #942: feat(helpers): add _HTTPClient

0 of 164 new or added lines in 1 file covered. (0.0%)

6379 of 8106 relevant lines covered (78.69%)

27.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/Sources/Helpers/_HTTPClient.swift
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.
NEW
50
  let jsonDecoder = JSONDecoder.supabase()
×
51

52
  init(
53
    host: URL, session: URLSession = URLSession(configuration: .default),
54
    tokenProvider: TokenProvider? = nil
NEW
55
  ) {
×
NEW
56
    self.host = host
×
NEW
57
    self.session = session
×
NEW
58
    self.tokenProvider = tokenProvider
×
NEW
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
NEW
68
  ) async throws -> (T, HTTPURLResponse) {
×
NEW
69
    let request = try await createRequest(method, path, query: query, body: body, headers: headers)
×
NEW
70
    return try await performFetch(request: request)
×
NEW
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
NEW
80
  ) async throws -> (T, HTTPURLResponse) {
×
NEW
81
    let request = try await createRequest(
×
NEW
82
      method, url: url, query: query, body: body, headers: headers)
×
NEW
83
    return try await performFetch(request: request)
×
NEW
84
  }
×
85

86
  private func performFetch<T: Decodable>(
87
    request: URLRequest
NEW
88
  ) async throws -> (T, HTTPURLResponse) {
×
NEW
89
    let (data, response) = try await performFetch(request: request)
×
NEW
90

×
NEW
91
    do {
×
NEW
92
      let value = try jsonDecoder.decode(T.self, from: data)
×
NEW
93
      return (value, response)
×
NEW
94
    } catch {
×
NEW
95
      throw HTTPClientError.decodingError(response, detail: error.localizedDescription)
×
NEW
96
    }
×
NEW
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
NEW
110
    ) -> AsyncThrowingStream<UInt8, any Error> {
×
NEW
111
      performFetchStream(
×
NEW
112
        method,
×
NEW
113
        requestBuilder: { [self] in
×
NEW
114
          try await self.createRequest(method, path, query: query, body: body, headers: headers)
×
NEW
115
        }
×
NEW
116
      )
×
NEW
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
NEW
126
    ) -> AsyncThrowingStream<UInt8, any Error> {
×
NEW
127
      performFetchStream(
×
NEW
128
        method,
×
NEW
129
        requestBuilder: { [self] in
×
NEW
130
          try await self.createRequest(method, url: url, query: query, body: body, headers: headers)
×
NEW
131
        }
×
NEW
132
      )
×
NEW
133
    }
×
134

135
    @available(macOS 12.0, *)
136
    private func performFetchStream(
137
      _ method: HTTPMethod, requestBuilder: @escaping @Sendable () async throws -> URLRequest
NEW
138
    ) -> AsyncThrowingStream<UInt8, any Error> {
×
NEW
139
      AsyncThrowingStream { continuation in
×
NEW
140
        let task = Task {
×
NEW
141
          do {
×
NEW
142
            let request = try await requestBuilder()
×
NEW
143

×
NEW
144
            let (bytes, response) = try await session.bytes(for: request)
×
NEW
145
            let httpResponse = try validateResponse(response)
×
NEW
146

×
NEW
147
            guard (200..<300).contains(httpResponse.statusCode) else {
×
NEW
148
              var errorData = Data()
×
NEW
149
              for try await byte in bytes {
×
NEW
150
                errorData.append(byte)
×
NEW
151
              }
×
NEW
152
              // validateResponse will throw the appropriate error
×
NEW
153
              _ = try validateResponse(response, data: errorData)
×
NEW
154
              return  // This line will never be reached, but satisfies the compiler
×
NEW
155
            }
×
NEW
156

×
NEW
157
            for try await byte in bytes {
×
NEW
158
              continuation.yield(byte)
×
NEW
159
            }
×
NEW
160

×
NEW
161
            continuation.finish()
×
NEW
162
          }
×
NEW
163
        }
×
NEW
164

×
NEW
165
        continuation.onTermination = { _ in
×
NEW
166
          task.cancel()
×
NEW
167
        }
×
NEW
168
      }
×
NEW
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
NEW
176
  ) async throws -> (Data, HTTPURLResponse) {
×
NEW
177
    let request = try await createRequest(method, path, query: query, body: body, headers: headers)
×
NEW
178
    return try await performFetch(request: request)
×
NEW
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
NEW
185
  ) async throws -> (Data, HTTPURLResponse) {
×
NEW
186
    let request = try await createRequest(
×
NEW
187
      method, url: url, query: query, body: body, headers: headers)
×
NEW
188
    return try await performFetch(request: request)
×
NEW
189
  }
×
190

191
  private func performFetch(
192
    request: URLRequest
NEW
193
  ) async throws -> (Data, HTTPURLResponse) {
×
NEW
194
    let (data, response) = try await session.data(for: request)
×
NEW
195
    let httpResponse = try validateResponse(response, data: data)
×
NEW
196
    return (data, httpResponse)
×
NEW
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
NEW
210
  ) async throws -> URLRequest {
×
NEW
211
    var urlComponents = URLComponents(url: host, resolvingAgainstBaseURL: true)
×
NEW
212
    urlComponents?.path = path
×
NEW
213

×
NEW
214
    return try await createRequest(
×
NEW
215
      method, urlComponents: urlComponents, query: query, body: body, headers: headers)
×
NEW
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
NEW
222
  ) async throws -> URLRequest {
×
NEW
223
    let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)
×
NEW
224

×
NEW
225
    return try await createRequest(
×
NEW
226
      method, urlComponents: urlComponents, query: query, body: body, headers: headers)
×
NEW
227
  }
×
228

229
  private func createRequest(
230
    _ method: HTTPMethod, urlComponents: URLComponents?, query: [String: String]? = nil,
231
    body: RequestBody? = nil, headers: [String: String]? = nil
NEW
232
  ) async throws -> URLRequest {
×
NEW
233
    var urlComponents = urlComponents
×
NEW
234

×
NEW
235
    if let query {
×
NEW
236
      var queryItems = urlComponents?.queryItems ?? []
×
NEW
237
      for (key, value) in query {
×
NEW
238
        queryItems.append(URLQueryItem(name: key, value: value))
×
NEW
239
      }
×
NEW
240
      urlComponents?.queryItems = queryItems
×
NEW
241
    }
×
NEW
242

×
NEW
243
    guard let url = urlComponents?.url else {
×
NEW
244
      throw URLError(.badURL)
×
NEW
245
    }
×
NEW
246

×
NEW
247
    var request = URLRequest(url: url)
×
NEW
248
    request.httpMethod = method.rawValue
×
NEW
249

×
NEW
250
    if let headers {
×
NEW
251
      for (key, value) in headers {
×
NEW
252
        request.setValue(value, forHTTPHeaderField: key)
×
NEW
253
      }
×
NEW
254
    }
×
NEW
255

×
NEW
256
    if let tokenProvider,
×
NEW
257
      request.value(forHTTPHeaderField: "Authorization") == nil,
×
NEW
258
      let token = try await tokenProvider()
×
NEW
259
    {
×
NEW
260
      request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
×
NEW
261
    }
×
NEW
262

×
NEW
263
    if request.value(forHTTPHeaderField: "Accept") == nil {
×
NEW
264
      request.setValue("application/json", forHTTPHeaderField: "Accept")
×
NEW
265
    }
×
NEW
266

×
NEW
267
    if let body {
×
NEW
268
      switch body {
×
NEW
269
      case .encodable(let value, let encoder):
×
NEW
270
        request.httpBody = try encoder.encode(value())
×
NEW
271
      case .json(let dict):
×
NEW
272
        request.httpBody = try JSONSerialization.data(withJSONObject: dict())
×
NEW
273
      case .data(let data):
×
NEW
274
        request.httpBody = data
×
NEW
275
      }
×
NEW
276
      if request.value(forHTTPHeaderField: "Content-Type") == nil {
×
NEW
277
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
×
NEW
278
      }
×
NEW
279
    }
×
NEW
280

×
NEW
281
    return request
×
NEW
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
NEW
290
  func validateResponse(_ response: URLResponse, data: Data? = nil) throws -> HTTPURLResponse {
×
NEW
291
    guard let response = response as? HTTPURLResponse else {
×
NEW
292
      throw HTTPClientError.unexpectedError(
×
NEW
293
        "Invalid response from server: \(response)"
×
NEW
294
      )
×
NEW
295
    }
×
NEW
296

×
NEW
297
    if let data, !(200..<300).contains(response.statusCode) {
×
NEW
298
      throw HTTPClientError.responseError(response, data: data)
×
NEW
299
    }
×
NEW
300

×
NEW
301
    return response
×
NEW
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
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc