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

supabase / supabase-swift / 25177042264

30 Apr 2026 04:28PM UTC coverage: 76.107% (-4.8%) from 80.858%
25177042264

Pull #987

github

web-flow
Merge e9a99e79a into 9e0637908
Pull Request #987: feat(storage)!: storage v3

578 of 1066 new or added lines in 7 files covered. (54.22%)

11 existing lines in 2 files now uncovered.

6756 of 8877 relevant lines covered (76.11%)

29.95 hits per line

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

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

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

8
/// Configuration for ``StorageClient``.
9
///
10
/// Pass an instance to ``StorageClient/init(url:configuration:)`` to customise the HTTP layer,
11
/// request headers, and optional diagnostics logging.
12
///
13
/// ## Example
14
///
15
/// ```swift
16
/// let config = StorageClientConfiguration(
17
///   headers: [
18
///     "apikey": "<anon-key>",
19
///     "Authorization": "Bearer <access-token>"
20
///   ],
21
///   logger: myLogger
22
/// )
23
/// let storage = StorageClient(
24
///   url: URL(string: "https://<project-ref>.supabase.co/storage/v1")!,
25
///   configuration: config
26
/// )
27
/// ```
28
public struct StorageClientConfiguration: Sendable {
29
  /// HTTP headers included in every request made by the client.
30
  ///
31
  /// An `X-Client-Info` header is appended automatically if not already present. The
32
  /// `Authorization` header set here is used unless a `TokenProvider` is configured via the
33
  /// package-level initialiser, in which case it is refreshed automatically.
34
  public var headers: [String: String]
35

36
  /// The `URLSession` used for all HTTP requests.
37
  ///
38
  /// Defaults to a session created with `.default` configuration.
39
  public let session: URLSession
40

41
  /// An optional logger that receives verbose request and response diagnostics.
42
  ///
43
  /// Implement `SupabaseLogger` and supply it here to observe all storage HTTP traffic.
44
  /// Pass `nil` (the default) to disable logging.
45
  public let logger: (any SupabaseLogger)?
46

47
  /// When `true`, rewrites legacy Supabase hostnames to use the dedicated storage subdomain,
48
  /// which disables request buffering and enables uploads larger than 50 GB.
49
  ///
50
  /// `<project-ref>.supabase.co` is rewritten to `<project-ref>.storage.supabase.co`.
51
  /// Defaults to `false`.
52
  public let useNewHostname: Bool
53

54
  /// Creates a ``StorageClientConfiguration``.
55
  ///
56
  /// - Parameters:
57
  ///   - headers: HTTP headers included in every request. An `X-Client-Info` header is added
58
  ///     automatically when absent.
59
  ///   - session: The `URLSession` to use. Defaults to a `.default` session.
60
  ///   - logger: An optional `SupabaseLogger` for request/response diagnostics. Defaults to `nil`.
61
  ///   - useNewHostname: When `true`, rewrites the host to the dedicated storage subdomain for
62
  ///     large-file upload support. Defaults to `false`.
63
  public init(
64
    headers: [String: String],
65
    session: URLSession = URLSession(configuration: .default),
66
    logger: (any SupabaseLogger)? = nil,
67
    useNewHostname: Bool = false
68
  ) {
54✔
69
    self.headers = headers
54✔
70
    self.session = session
54✔
71
    self.logger = logger
54✔
72
    self.useNewHostname = useNewHostname
54✔
73
  }
54✔
74
}
75

76
/// A client for managing Supabase Storage buckets and files.
77
///
78
/// `StorageClient` is the entry point for all Storage operations. Use it to manage buckets
79
/// directly, or call ``from(_:)`` to obtain a ``StorageFileAPI`` for file operations within a
80
/// specific bucket.
81
///
82
/// ## Basic usage
83
///
84
/// ```swift
85
/// let storage = StorageClient(
86
///   url: URL(string: "https://<project-ref>.supabase.co/storage/v1")!,
87
///   configuration: StorageClientConfiguration(
88
///     headers: ["apikey": "<anon-key>", "Authorization": "Bearer <access-token>"]
89
///   )
90
/// )
91
///
92
/// // Create a bucket
93
/// try await storage.createBucket("avatars", options: BucketOptions(isPublic: true))
94
///
95
/// // Upload a file
96
/// try await storage.from("avatars").upload("user.png", data: imageData)
97
///
98
/// // Get public URL
99
/// let url = try storage.from("avatars").getPublicURL(path: "user.png")
100
/// ```
101
///
102
/// When using ``SupabaseClient``, the storage client is pre-configured and accessible via
103
/// `supabase.storage`.
104
///
105
/// - Note: All state is set at initialisation and never mutated, making `StorageClient` safe to
106
///   share across concurrency boundaries.
107
public final class StorageClient: Sendable {
108
  /// The base URL of the Storage API, e.g. `https://<project-ref>.supabase.co/storage/v1`.
109
  public let url: URL
110

111
  /// The configuration used by this client, including headers, session, and logging preferences.
112
  public let configuration: StorageClientConfiguration
113

114
  package let http: _HTTPClient
115
  private let usesTokenProvider: Bool
116

117
  let encoder: JSONEncoder = {
54✔
118
    let encoder = JSONEncoder.supabase()
54✔
119
    encoder.keyEncodingStrategy = .convertToSnakeCase
54✔
120
    return encoder
54✔
121
  }()
54✔
122

123
  let decoder = JSONDecoder.supabase()
54✔
124

125
  /// Creates a `StorageClient` for standalone use (without a ``SupabaseClient``).
126
  ///
127
  /// Use this initialiser when you want to interact with Supabase Storage independently, without
128
  /// the broader Supabase client stack. For most apps, create a ``SupabaseClient`` and access
129
  /// its `storage` property instead.
130
  ///
131
  /// - Parameters:
132
  ///   - url: The base URL for the Storage endpoint,
133
  ///     e.g. `https://<project-ref>.supabase.co/storage/v1`.
134
  ///   - configuration: The client configuration, including authentication headers, URL session,
135
  ///     and logging preferences.
136
  ///
137
  /// ## Example
138
  ///
139
  /// ```swift
140
  /// let storage = StorageClient(
141
  ///   url: URL(string: "https://<project-ref>.supabase.co/storage/v1")!,
142
  ///   configuration: StorageClientConfiguration(
143
  ///     headers: ["apikey": "<anon-key>", "Authorization": "Bearer <access-token>"]
144
  ///   )
145
  /// )
146
  /// ```
147
  public convenience init(url: URL, configuration: StorageClientConfiguration) {
53✔
148
    self.init(url: url, configuration: configuration, tokenProvider: nil)
53✔
149
  }
53✔
150

151
  package init(url: URL, configuration: StorageClientConfiguration, tokenProvider: TokenProvider?) {
54✔
152
    var configuration = configuration
54✔
153

54✔
154
    let clientInfoHeader = "X-Client-Info"
54✔
155
    let clientInfoHeaders = configuration.headers.keys.filter {
54✔
156
      $0.caseInsensitiveCompare(clientInfoHeader) == .orderedSame
52✔
157
    }
52✔
158

54✔
159
    if let firstClientInfoHeader = clientInfoHeaders.first {
54✔
160
      let clientInfo = configuration.headers[firstClientInfoHeader]
5✔
161
      for duplicateHeader in clientInfoHeaders.dropFirst() {
5✔
NEW
162
        configuration.headers.removeValue(forKey: duplicateHeader)
×
163
      }
5✔
164

5✔
165
      if firstClientInfoHeader != clientInfoHeader {
5✔
NEW
166
        configuration.headers.removeValue(forKey: firstClientInfoHeader)
×
NEW
167
        configuration.headers[clientInfoHeader] = clientInfo
×
NEW
168
      }
×
169
    } else {
49✔
170
      configuration.headers["X-Client-Info"] = "storage-swift/\(version)"
49✔
171
    }
49✔
172

54✔
173
    var resolvedURL = url
54✔
174

54✔
175
    // if legacy uri is used, replace with new storage host (disables request buffering to allow > 50GB uploads)
54✔
176
    // "project-ref.supabase.co" becomes "project-ref.storage.supabase.co"
54✔
177
    if configuration.useNewHostname == true {
54✔
178
      guard
5✔
179
        var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
5✔
180
        let host = components.host
5✔
181
      else {
5✔
NEW
182
        fatalError("Client initialized with invalid URL: \(url)")
×
183
      }
5✔
184

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

5✔
187
      let isSupabaseHost =
5✔
188
        regex.firstMatch(
5✔
189
          in: host,
5✔
190
          range: NSRange(location: 0, length: host.utf16.count)
5✔
191
        ) != nil
5✔
192

5✔
193
      if isSupabaseHost, !host.contains("storage.supabase.") {
5✔
194
        components.host = host.replacingOccurrences(
2✔
195
          of: "supabase.",
2✔
196
          with: "storage.supabase."
2✔
197
        )
2✔
198
      }
2✔
199

5✔
200
      resolvedURL = components.url!
5✔
201
    }
54✔
202

54✔
203
    self.url = resolvedURL
54✔
204
    self.configuration = configuration
54✔
205
    usesTokenProvider = tokenProvider != nil
54✔
206

54✔
207
    http = _HTTPClient(
54✔
208
      host: resolvedURL,
54✔
209
      session: configuration.session,
54✔
210
      tokenProvider: tokenProvider
54✔
211
    )
54✔
212
  }
54✔
213

214
  func mergedHeaders(_ headers: [String: String]? = nil) -> [String: String] {
38✔
215
    var merged = configuration.headers
38✔
216

38✔
217
    for (key, value) in headers ?? [:] {
38✔
218
      if let existingKey = merged.keys.first(where: {
62✔
219
        $0.caseInsensitiveCompare(key) == .orderedSame
62✔
220
      }) {
62✔
NEW
221
        merged[existingKey] = value
×
222
      } else {
21✔
223
        merged[key] = value
21✔
224
      }
21✔
225
    }
38✔
226

38✔
227
    if usesTokenProvider {
38✔
NEW
228
      merged = merged.filter {
×
NEW
229
        $0.key.caseInsensitiveCompare("Authorization") != .orderedSame
×
NEW
230
      }
×
NEW
231
    }
×
232

38✔
233
    return merged
38✔
234
  }
38✔
235

236
  @discardableResult
237
  func fetchData(
238
    _ method: HTTPMethod,
239
    _ path: String,
240
    query: [String: String]? = nil,
241
    body: RequestBody? = nil,
242
    headers: [String: String]? = nil
243
  ) async throws -> (Data, HTTPURLResponse) {
26✔
244
    let url = self.url.appendingPathComponent(path)
26✔
245

26✔
246
    do {
26✔
247
      logRequest(method, url: url)
26✔
248
      let result = try await http.fetchData(
26✔
249
        method,
26✔
250
        url: url,
26✔
251
        query: query,
26✔
252
        body: body,
26✔
253
        headers: mergedHeaders(headers)
26✔
254
      )
26✔
255
      logResponse(result.1, data: result.0)
22✔
256
      return result
22✔
257
    } catch {
26✔
258
      logFailure(error)
4✔
259
      throw translateStorageError(error)
4✔
260
    }
4✔
261
  }
26✔
262

263
  @discardableResult
264
  func fetchData(
265
    _ method: HTTPMethod,
266
    url: URL,
267
    query: [String: String]? = nil,
268
    body: RequestBody? = nil,
269
    headers: [String: String]? = nil
270
  ) async throws -> (Data, HTTPURLResponse) {
5✔
271
    do {
5✔
272
      logRequest(method, url: url)
5✔
273
      let result = try await http.fetchData(
5✔
274
        method,
5✔
275
        url: url,
5✔
276
        query: query,
5✔
277
        body: body,
5✔
278
        headers: mergedHeaders(headers)
5✔
279
      )
5✔
280
      logResponse(result.1, data: result.0)
5✔
281
      return result
5✔
282
    } catch {
5✔
NEW
283
      logFailure(error)
×
NEW
284
      throw translateStorageError(error)
×
NEW
285
    }
×
286
  }
5✔
287

288
  func fetchDecoded<T: Decodable>(
289
    _ method: HTTPMethod,
290
    _ path: String,
291
    query: [String: String]? = nil,
292
    body: RequestBody? = nil,
293
    headers: [String: String]? = nil,
294
    as _: T.Type = T.self
295
  ) async throws -> T {
16✔
296
    let (data, _) = try await fetchData(method, path, query: query, body: body, headers: headers)
16✔
297
    return try decoder.decode(T.self, from: data)
16✔
298
  }
16✔
299

NEW
300
  func translateStorageError(_ error: any Error) -> any Error {
×
NEW
301
    guard case HTTPClientError.responseError(let response, let data) = error else {
×
NEW
302
      return error
×
NEW
303
    }
×
NEW
304

×
NEW
305
    let decoded = try? decoder.decode(ServerErrorResponse.self, from: data)
×
NEW
306
    return StorageError(
×
NEW
307
      message: decoded?.message ?? String(data: data, encoding: .utf8) ?? "Unknown error",
×
NEW
308
      errorCode: decoded?.error.map(StorageErrorCode.init(_:)) ?? .unknown,
×
NEW
309
      statusCode: decoded?.statusCode.flatMap(Int.init) ?? response.statusCode,
×
NEW
310
      underlyingResponse: response,
×
NEW
311
      underlyingData: data
×
NEW
312
    )
×
NEW
313
  }
×
314

315
  private struct ServerErrorResponse: Decodable {
316
    let message: String
317
    let error: String?
318
    /// The server sends the status code as a JSON string, e.g. `"404"`.
319
    let statusCode: String?
320
  }
321

322
  func logRequest(_ method: HTTPMethod, url: URL) {
38✔
323
    configuration.logger?.verbose(
38✔
324
      "Request: \(method.rawValue) \(url.absoluteString.removingPercentEncoding ?? url.absoluteString)"
38✔
325
    )
38✔
326
  }
38✔
327

328
  func logResponse(_ response: HTTPURLResponse, data: Data) {
34✔
329
    configuration.logger?.verbose(
34✔
330
      "Response: Status code: \(response.statusCode) Content-Length: \(data.count)"
34✔
331
    )
34✔
332
  }
34✔
333

334
  func logFailure(_ error: any Error) {
4✔
335
    configuration.logger?.error("Response: Failure \(error)")
4✔
336
  }
4✔
337

338
  /// Returns a ``StorageFileAPI`` scoped to the given bucket.
339
  ///
340
  /// All file operations — upload, download, list, delete, signed URLs — are performed through
341
  /// the returned ``StorageFileAPI`` instance.
342
  ///
343
  /// - Parameter id: The unique identifier of the bucket to operate on.
344
  /// - Returns: A ``StorageFileAPI`` bound to the specified bucket.
345
  ///
346
  /// ## Example
347
  ///
348
  /// ```swift
349
  /// let avatarsBucket = storage.from("avatars")
350
  /// let data = try await avatarsBucket.download(path: "user-123/photo.png")
351
  /// ```
352
  public func from(_ id: String) -> StorageFileAPI {
39✔
353
    StorageFileAPI(bucketId: id, client: self)
39✔
354
  }
39✔
355

356
  /// Retrieves the details of all Storage buckets within the project.
357
  ///
358
  /// - Returns: An array of ``Bucket`` values, one per existing bucket.
359
  /// - Throws: ``StorageError`` if the request fails.
360
  ///
361
  /// ## Example
362
  ///
363
  /// ```swift
364
  /// let buckets = try await storage.listBuckets()
365
  /// for bucket in buckets {
366
  ///   print("\(bucket.id) — public: \(bucket.isPublic)")
367
  /// }
368
  /// ```
369
  public func listBuckets() async throws -> [Bucket] {
1✔
370
    try await fetchDecoded(.get, "bucket")
1✔
371
  }
1✔
372

373
  /// Retrieves the details of a single Storage bucket.
374
  ///
375
  /// - Parameter id: The unique identifier of the bucket to retrieve.
376
  /// - Returns: The matching ``Bucket``.
377
  /// - Throws: ``StorageError`` if the bucket does not exist or the request fails.
378
  ///
379
  /// ## Example
380
  ///
381
  /// ```swift
382
  /// let bucket = try await storage.getBucket("avatars")
383
  /// print("Bucket is \(bucket.isPublic ? "public" : "private")")
384
  /// ```
385
  public func getBucket(_ id: String) async throws -> Bucket {
1✔
386
    try await fetchDecoded(.get, "bucket/\(id)")
1✔
387
  }
1✔
388

389
  struct BucketParameters: Encodable {
390
    var id: String
391
    var name: String
392
    var isPublic: Bool
393
    var fileSizeLimit: Int64?
394
    var allowedMimeTypes: [String]?
395

396
    // Explicit CodingKeys required: keyEncodingStrategy (.convertToSnakeCase) would map
397
    // `isPublic` → `is_public`, but the backend wire key must be `"public"`.
398
    enum CodingKeys: String, CodingKey {
399
      case id
400
      case name
401
      case isPublic = "public"
402
      case fileSizeLimit = "file_size_limit"
403
      case allowedMimeTypes = "allowed_mime_types"
404
    }
405
  }
406

407
  /// Creates a new Storage bucket.
408
  ///
409
  /// - Parameters:
410
  ///   - id: A unique identifier for the bucket. Used as both the ID and display name.
411
  ///   - options: Visibility, file-size limit, and MIME-type restrictions for the bucket.
412
  ///     Defaults to a private bucket with no restrictions.
413
  /// - Throws: ``StorageError`` if the bucket already exists or the request fails.
414
  ///
415
  /// ## Example
416
  ///
417
  /// ```swift
418
  /// // Create a private bucket restricted to images ≤ 5 MB
419
  /// try await storage.createBucket(
420
  ///   "avatars",
421
  ///   options: BucketOptions(
422
  ///     isPublic: false,
423
  ///     fileSizeLimit: .megabytes(5),
424
  ///     allowedMimeTypes: ["image/*"]
425
  ///   )
426
  /// )
427
  /// ```
428
  public func createBucket(_ id: String, options: BucketOptions = .init())
429
    async throws
430
  {
1✔
431
    try await fetchData(
1✔
432
      .post,
1✔
433
      "bucket",
1✔
434
      body: .data(
1✔
435
        encoder.encode(
1✔
436
          BucketParameters(
1✔
437
            id: id,
1✔
438
            name: id,
1✔
439
            isPublic: options.isPublic,
1✔
440
            fileSizeLimit: options.fileSizeLimit?.bytes,
1✔
441
            allowedMimeTypes: options.allowedMimeTypes
1✔
442
          )
1✔
443
        )
1✔
444
      )
1✔
445
    )
1✔
446
  }
1✔
447

448
  /// Updates the configuration of an existing Storage bucket.
449
  ///
450
  /// - Parameters:
451
  ///   - id: The unique identifier of the bucket to update.
452
  ///   - options: The new bucket options. All fields replace the existing configuration.
453
  /// - Throws: ``StorageError`` if the bucket does not exist or the request fails.
454
  ///
455
  /// ## Example
456
  ///
457
  /// ```swift
458
  /// // Make an existing bucket public
459
  /// try await storage.updateBucket("avatars", options: BucketOptions(isPublic: true))
460
  /// ```
461
  public func updateBucket(_ id: String, options: BucketOptions) async throws {
1✔
462
    try await fetchData(
1✔
463
      .put,
1✔
464
      "bucket/\(id)",
1✔
465
      body: .data(
1✔
466
        encoder.encode(
1✔
467
          BucketParameters(
1✔
468
            id: id,
1✔
469
            name: id,
1✔
470
            isPublic: options.isPublic,
1✔
471
            fileSizeLimit: options.fileSizeLimit?.bytes,
1✔
472
            allowedMimeTypes: options.allowedMimeTypes
1✔
473
          )
1✔
474
        )
1✔
475
      )
1✔
476
    )
1✔
477
  }
1✔
478

479
  /// Removes all objects inside a bucket without deleting the bucket itself.
480
  ///
481
  /// This is a prerequisite for ``deleteBucket(_:)``, which cannot remove a bucket that
482
  /// contains objects.
483
  ///
484
  /// - Parameter id: The unique identifier of the bucket to empty.
485
  /// - Throws: ``StorageError`` if the bucket does not exist or the request fails.
486
  ///
487
  /// ## Example
488
  ///
489
  /// ```swift
490
  /// try await storage.emptyBucket("temp-uploads")
491
  /// try await storage.deleteBucket("temp-uploads")
492
  /// ```
493
  public func emptyBucket(_ id: String) async throws {
1✔
494
    try await fetchData(.post, "bucket/\(id)/empty")
1✔
495
  }
1✔
496

497
  /// Deletes an existing Storage bucket.
498
  ///
499
  /// The bucket must be empty before it can be deleted. Call ``emptyBucket(_:)`` first to
500
  /// remove all objects, or delete individual files using ``StorageFileAPI/remove(paths:)``.
501
  ///
502
  /// - Parameter id: The unique identifier of the bucket to delete.
503
  /// - Throws: ``StorageError`` if the bucket is not empty, does not exist, or the request fails.
504
  ///
505
  /// ## Example
506
  ///
507
  /// ```swift
508
  /// try await storage.emptyBucket("old-bucket")
509
  /// try await storage.deleteBucket("old-bucket")
510
  /// ```
511
  public func deleteBucket(_ id: String) async throws {
1✔
512
    try await fetchData(.delete, "bucket/\(id)")
1✔
513
  }
1✔
514
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc