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

supabase / supabase-swift / 25106346352

29 Apr 2026 11:31AM UTC coverage: 80.475% (-0.4%) from 80.858%
25106346352

Pull #946

github

web-flow
Merge 8aa55b2df into 9e0637908
Pull Request #946: feat(storage): add multipart upload support [wip]

612 of 696 new or added lines in 4 files covered. (87.93%)

4 existing lines in 1 file now uncovered.

7114 of 8840 relevant lines covered (80.48%)

30.28 hits per line

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

93.46
/Sources/Storage/StorageClient.swift
1
import Foundation
2
import Helpers
3

4
#if canImport(FoundationNetworking)
5
  import FoundationNetworking
6
#endif
7

8
public struct StorageClientConfiguration: Sendable {
9
  public var url: URL
10
  public var headers: [String: String]
11
  public let encoder: JSONEncoder
12
  public let decoder: JSONDecoder
13
  public let session: URLSession
14
  public let logger: (any SupabaseLogger)?
15
  public let useNewHostname: Bool
16

17
  public init(
18
    url: URL,
19
    headers: [String: String],
20
    encoder: JSONEncoder? = nil,
21
    decoder: JSONDecoder? = nil,
22
    session: URLSession = URLSession(configuration: .default),
23
    logger: (any SupabaseLogger)? = nil,
24
    useNewHostname: Bool = false
25
  ) {
54✔
26
    self.url = url
54✔
27
    self.headers = headers
54✔
28
    self.encoder =
54✔
29
      encoder
54✔
30
      ?? {
54✔
31
        let encoder = JSONEncoder.supabase()
54✔
32
        encoder.keyEncodingStrategy = .convertToSnakeCase
54✔
33
        return encoder
54✔
34
      }()
54✔
35
    self.decoder = decoder ?? .supabase()
54✔
36
    self.session = session
54✔
37
    self.logger = logger
54✔
38
    self.useNewHostname = useNewHostname
54✔
39
  }
54✔
40
}
41

42
/// Supabase Storage client for managing buckets and files.
43
///
44
/// - Note: Thread Safety: Inherits immutable design from `StorageApi`. All state is set at
45
///   initialization and never mutated.
46
public final class StorageClient: Sendable {
47
  public let configuration: StorageClientConfiguration
48

49
  package let http: _HTTPClient
50
  private let usesTokenProvider: Bool
51

52
  public convenience init(configuration: StorageClientConfiguration) {
53✔
53
    self.init(configuration: configuration, tokenProvider: nil)
53✔
54
  }
53✔
55

56
  package init(configuration: StorageClientConfiguration, tokenProvider: TokenProvider?) {
54✔
57
    var configuration = configuration
54✔
58

54✔
59
    let clientInfoHeader = "X-Client-Info"
54✔
60
    let clientInfoHeaders = configuration.headers.keys.filter {
54✔
61
      $0.caseInsensitiveCompare(clientInfoHeader) == .orderedSame
52✔
62
    }
52✔
63

54✔
64
    if let firstClientInfoHeader = clientInfoHeaders.first {
54✔
65
      let clientInfo = configuration.headers[firstClientInfoHeader]
5✔
66
      for duplicateHeader in clientInfoHeaders.dropFirst() {
5✔
NEW
67
        configuration.headers.removeValue(forKey: duplicateHeader)
×
68
      }
5✔
69

5✔
70
      if firstClientInfoHeader != clientInfoHeader {
5✔
NEW
71
        configuration.headers.removeValue(forKey: firstClientInfoHeader)
×
NEW
72
        configuration.headers[clientInfoHeader] = clientInfo
×
NEW
73
      }
×
74
    } else {
49✔
75
      configuration.headers["X-Client-Info"] = "storage-swift/\(version)"
49✔
76
    }
49✔
77

54✔
78
    // if legacy uri is used, replace with new storage host (disables request buffering to allow > 50GB uploads)
54✔
79
    // "project-ref.supabase.co" becomes "project-ref.storage.supabase.co"
54✔
80
    if configuration.useNewHostname == true {
54✔
81
      guard
5✔
82
        var components = URLComponents(
5✔
83
          url: configuration.url,
5✔
84
          resolvingAgainstBaseURL: false
5✔
85
        ),
5✔
86
        let host = components.host
5✔
87
      else {
5✔
NEW
88
        fatalError("Client initialized with invalid URL: \(configuration.url)")
×
89
      }
5✔
90

5✔
91
      let regex = try! NSRegularExpression(pattern: "supabase.(co|in|red)$")
5✔
92

5✔
93
      let isSupabaseHost =
5✔
94
        regex.firstMatch(
5✔
95
          in: host,
5✔
96
          range: NSRange(location: 0, length: host.utf16.count)
5✔
97
        ) != nil
5✔
98

5✔
99
      if isSupabaseHost, !host.contains("storage.supabase.") {
5✔
100
        components.host = host.replacingOccurrences(
2✔
101
          of: "supabase.",
2✔
102
          with: "storage.supabase."
2✔
103
        )
2✔
104
      }
2✔
105

5✔
106
      configuration.url = components.url!
5✔
107
    }
54✔
108

54✔
109
    self.configuration = configuration
54✔
110
    usesTokenProvider = tokenProvider != nil
54✔
111

54✔
112
    http = _HTTPClient(
54✔
113
      host: configuration.url,
54✔
114
      session: configuration.session,
54✔
115
      tokenProvider: tokenProvider
54✔
116
    )
54✔
117
  }
54✔
118

119
  func mergedHeaders(_ headers: [String: String]? = nil) -> [String: String] {
38✔
120
    var merged = configuration.headers
38✔
121

38✔
122
    for (key, value) in headers ?? [:] {
38✔
123
      if let existingKey = merged.keys.first(where: {
62✔
124
        $0.caseInsensitiveCompare(key) == .orderedSame
62✔
125
      }) {
62✔
NEW
126
        merged[existingKey] = value
×
127
      } else {
21✔
128
        merged[key] = value
21✔
129
      }
21✔
130
    }
38✔
131

38✔
132
    if usesTokenProvider {
38✔
NEW
133
      merged = merged.filter {
×
NEW
134
        $0.key.caseInsensitiveCompare("Authorization") != .orderedSame
×
NEW
135
      }
×
NEW
136
    }
×
137

38✔
138
    return merged
38✔
139
  }
38✔
140

141
  @discardableResult
142
  func fetchData(
143
    _ method: HTTPMethod,
144
    _ path: String,
145
    query: [String: String]? = nil,
146
    body: RequestBody? = nil,
147
    headers: [String: String]? = nil
148
  ) async throws -> (Data, HTTPURLResponse) {
26✔
149
    let url = configuration.url.appendingPathComponent(path)
26✔
150

26✔
151
    do {
26✔
152
      logRequest(method, url: url)
26✔
153
      let result = try await http.fetchData(
26✔
154
        method,
26✔
155
        url: url,
26✔
156
        query: query,
26✔
157
        body: body,
26✔
158
        headers: mergedHeaders(headers)
26✔
159
      )
26✔
160
      logResponse(result.1, data: result.0)
22✔
161
      return result
22✔
162
    } catch {
26✔
163
      logFailure(error)
4✔
164
      throw translateStorageError(error)
4✔
165
    }
4✔
166
  }
26✔
167

168
  @discardableResult
169
  func fetchData(
170
    _ method: HTTPMethod,
171
    url: URL,
172
    query: [String: String]? = nil,
173
    body: RequestBody? = nil,
174
    headers: [String: String]? = nil
175
  ) async throws -> (Data, HTTPURLResponse) {
5✔
176
    do {
5✔
177
      logRequest(method, url: url)
5✔
178
      let result = try await http.fetchData(
5✔
179
        method,
5✔
180
        url: url,
5✔
181
        query: query,
5✔
182
        body: body,
5✔
183
        headers: mergedHeaders(headers)
5✔
184
      )
5✔
185
      logResponse(result.1, data: result.0)
5✔
186
      return result
5✔
187
    } catch {
5✔
NEW
188
      logFailure(error)
×
NEW
189
      throw translateStorageError(error)
×
NEW
190
    }
×
191
  }
5✔
192

193
  func fetchDecoded<T: Decodable>(
194
    _ method: HTTPMethod,
195
    _ path: String,
196
    query: [String: String]? = nil,
197
    body: RequestBody? = nil,
198
    headers: [String: String]? = nil,
199
    as _: T.Type = T.self
200
  ) async throws -> T {
16✔
201
    let (data, _) = try await fetchData(method, path, query: query, body: body, headers: headers)
16✔
202
    return try configuration.decoder.decode(T.self, from: data)
16✔
203
  }
16✔
204

205
  private func translateStorageError(_ error: any Error) -> any Error {
4✔
206
    guard case HTTPClientError.responseError(let response, let data) = error else {
4✔
NEW
207
      return error
×
208
    }
4✔
209

4✔
210
    if let storageError = try? configuration.decoder.decode(StorageError.self, from: data) {
4✔
211
      return storageError
1✔
212
    }
3✔
213

3✔
214
    return HTTPError(data: data, response: response)
3✔
215
  }
4✔
216

217
  func logRequest(_ method: HTTPMethod, url: URL) {
38✔
218
    configuration.logger?.verbose(
38✔
219
      "Request: \(method.rawValue) \(url.absoluteString.removingPercentEncoding ?? url.absoluteString)"
38✔
220
    )
38✔
221
  }
38✔
222

223
  func logResponse(_ response: HTTPURLResponse, data: Data) {
34✔
224
    configuration.logger?.verbose(
34✔
225
      "Response: Status code: \(response.statusCode) Content-Length: \(data.count)"
34✔
226
    )
34✔
227
  }
34✔
228

229
  func logFailure(_ error: any Error) {
4✔
230
    configuration.logger?.error("Response: Failure \(error)")
4✔
231
  }
4✔
232

233
  /// Perform file operation in a bucket.
234
  /// - Parameter id: The bucket id to operate on.
235
  /// - Returns: StorageFileAPI object
236
  public func from(_ id: String) -> StorageFileAPI {
39✔
237
    StorageFileAPI(bucketId: id, client: self)
39✔
238
  }
39✔
239

240
  /// Retrieves the details of all Storage buckets within an existing project.
241
  public func listBuckets() async throws -> [Bucket] {
1✔
242
    try await fetchDecoded(.get, "bucket")
1✔
243
  }
1✔
244

245
  /// Retrieves the details of an existing Storage bucket.
246
  /// - Parameters:
247
  ///   - id: The unique identifier of the bucket you would like to retrieve.
248
  public func getBucket(_ id: String) async throws -> Bucket {
1✔
249
    try await fetchDecoded(.get, "bucket/\(id)")
1✔
250
  }
1✔
251

252
  struct BucketParameters: Encodable {
253
    var id: String
254
    var name: String
255
    var `public`: Bool
256
    var fileSizeLimit: String?
257
    var allowedMimeTypes: [String]?
258
  }
259

260
  /// Creates a new Storage bucket.
261
  /// - Parameters:
262
  ///   - id: A unique identifier for the bucket you are creating.
263
  ///   - options: Options for creating the bucket.
264
  public func createBucket(_ id: String, options: BucketOptions = .init())
265
    async throws
266
  {
1✔
267
    try await fetchData(
1✔
268
      .post,
1✔
269
      "bucket",
1✔
270
      body: .data(
1✔
271
        configuration.encoder.encode(
1✔
272
          BucketParameters(
1✔
273
            id: id,
1✔
274
            name: id,
1✔
275
            public: options.public,
1✔
276
            fileSizeLimit: options.fileSizeLimit,
1✔
277
            allowedMimeTypes: options.allowedMimeTypes
1✔
278
          )
1✔
279
        )
1✔
280
      )
1✔
281
    )
1✔
282
  }
1✔
283

284
  /// Updates a Storage bucket.
285
  /// - Parameters:
286
  ///   - id: A unique identifier for the bucket you are updating.
287
  ///   - options: Options for updating the bucket.
288
  public func updateBucket(_ id: String, options: BucketOptions) async throws {
1✔
289
    try await fetchData(
1✔
290
      .put,
1✔
291
      "bucket/\(id)",
1✔
292
      body: .data(
1✔
293
        configuration.encoder.encode(
1✔
294
          BucketParameters(
1✔
295
            id: id,
1✔
296
            name: id,
1✔
297
            public: options.public,
1✔
298
            fileSizeLimit: options.fileSizeLimit,
1✔
299
            allowedMimeTypes: options.allowedMimeTypes
1✔
300
          )
1✔
301
        )
1✔
302
      )
1✔
303
    )
1✔
304
  }
1✔
305

306
  /// Removes all objects inside a single bucket.
307
  /// - Parameters:
308
  ///   - id: The unique identifier of the bucket you would like to empty.
309
  public func emptyBucket(_ id: String) async throws {
1✔
310
    try await fetchData(.post, "bucket/\(id)/empty")
1✔
311
  }
1✔
312

313
  /// Deletes an existing bucket. A bucket can't be deleted with existing objects inside it.
314
  /// You must first `empty()` the bucket.
315
  /// - Parameters:
316
  ///   - id: The unique identifier of the bucket you would like to delete.
317
  public func deleteBucket(_ id: String) async throws {
1✔
318
    try await fetchData(.delete, "bucket/\(id)")
1✔
319
  }
1✔
320
}
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