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

supabase / supabase-swift / 25570747588

08 May 2026 05:50PM UTC coverage: 80.732% (-0.1%) from 80.858%
25570747588

Pull #987

github

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

956 of 1064 new or added lines in 7 files covered. (89.85%)

6 existing lines in 2 files now uncovered.

7165 of 8875 relevant lines covered (80.73%)

30.31 hits per line

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

92.66
/Sources/Storage/StorageFileAPI.swift
1
import Foundation
2
import Helpers
3
import XCTestDynamicOverlay
4

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

9
let defaultSearchOptions = SearchOptions(
10
  limit: 100,
11
  offset: 0,
12
  sortBy: SortBy(
13
    column: "name",
14
    order: .ascending
15
  )
16
)
17

18
let defaultFileOptions = FileOptions(
19
  cacheControl: "3600",
20
  contentType: "text/plain;charset=UTF-8",
21
  upsert: false
22
)
23

24
enum FileUpload {
25
  case data(Data)
26
  case url(URL)
27

28
  func append(
29
    to builder: MultipartBuilder,
30
    withPath path: String,
31
    options: FileOptions
32
  ) -> MultipartBuilder {
8✔
33
    var builder = builder.addText(
8✔
34
      name: "cacheControl",
8✔
35
      value: options.cacheControl
8✔
36
    )
8✔
37

8✔
38
    if let metadata = options.metadata {
8✔
39
      builder = builder.addText(
4✔
40
        name: "metadata",
4✔
41
        value: String(data: encodeMetadata(metadata), encoding: .utf8) ?? ""
4✔
42
      )
4✔
43
    }
4✔
44

8✔
45
    switch self {
8✔
46
    case .data(let data):
8✔
47
      return builder.addData(
4✔
48
        name: "",
4✔
49
        data: data,
4✔
50
        fileName: path.fileName,
4✔
51
        mimeType: options.contentType
4✔
52
          ?? mimeType(forPathExtension: path.pathExtension)
4✔
53
      )
4✔
54

8✔
55
    case .url(let url):
8✔
56
      return builder.addFile(
4✔
57
        name: "",
4✔
58
        fileURL: url,
4✔
59
        fileName: url.lastPathComponent,
4✔
60
        mimeType: options.contentType
4✔
61
          ?? mimeType(forPathExtension: url.pathExtension)
4✔
62
      )
4✔
63
    }
8✔
64
  }
8✔
65

66
  var usesTempFileUpload: Bool {
67
    get throws {
8✔
68
      guard case .url(let url) = self else { return false }
8✔
69

4✔
70
      let fileSize =
4✔
71
        try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
4✔
72
      return fileSize >= 10 * 1024 * 1024
4✔
73
    }
8✔
74
  }
75

NEW
76
  func defaultOptions() -> FileOptions {
×
NEW
77
    switch self {
×
NEW
78
    case .data:
×
NEW
79
      return defaultFileOptions
×
NEW
80

×
NEW
81
    case .url:
×
NEW
82
      var options = defaultFileOptions
×
NEW
83
      options.contentType = nil
×
NEW
84
      return options
×
NEW
85
    }
×
NEW
86
  }
×
87
}
88

89
#if DEBUG
90
  import ConcurrencyExtras
91
  let testingBoundary = LockIsolated<String?>(nil)
1✔
92
#endif
93

94
/// File operations API for objects stored within a single Supabase Storage bucket.
95
///
96
/// Obtain an instance by calling ``StorageClient/from(_:)`` — do not create one directly.
97
///
98
/// `StorageFileAPI` covers the full lifecycle of objects in a bucket: uploading, updating,
99
/// downloading, listing, deleting, moving, copying, and generating signed or public URLs.
100
///
101
/// ## Basic usage
102
///
103
/// ```swift
104
/// let bucket = supabase.storage.from("avatars")
105
///
106
/// // Upload
107
/// let response = try await bucket.upload("user-123/photo.png", data: imageData)
108
///
109
/// // Download
110
/// let data = try await bucket.download(path: "user-123/photo.png")
111
///
112
/// // Public URL (public bucket)
113
/// let url = try bucket.getPublicURL(path: "user-123/photo.png")
114
///
115
/// // Signed URL (private bucket, valid for 60 seconds)
116
/// let signedURL = try await bucket.createSignedURL(path: "user-123/photo.png", expiresIn: 60)
117
/// ```
118
///
119
/// - Note: All stored properties are immutable, making `StorageFileAPI` safe to share across
120
///   concurrency boundaries.
121
public struct StorageFileAPI: Sendable {
122
  /// The bucket id to operate on.
123
  let bucketId: String
124
  let client: StorageClient
125

126
  /// JSONEncoder with default key strategy used when we want `camelCase` keys instead of the default
127
  /// `snake_case` used in Storage services.
128
  let originalEncoder: JSONEncoder = {
40✔
129
    let encoder = JSONEncoder()
40✔
130
    #if DEBUG
131
      if isTesting {
40✔
132
        encoder.outputFormatting = .sortedKeys
40✔
133
      }
40✔
134
    #endif
135
    return encoder
40✔
136
  }()
40✔
137

138
  init(bucketId: String, client: StorageClient) {
40✔
139
    self.bucketId = bucketId
40✔
140
    self.client = client
40✔
141
  }
40✔
142

143
  private struct MoveResponse: Decodable {
144
    let message: String
145
  }
146

147
  private struct SignedURLAPIResponse: Decodable {
148
    let signedURL: String
149
  }
150

151
  private struct SignedURLsAPIResponse: Decodable {
152
    let signedURL: String?
153
    let path: String
154
    let error: String?
155
  }
156

157
  private enum Header {
158
    static let cacheControl = "Cache-Control"
159
    static let contentType = "Content-Type"
160
    static let xUpsert = "x-upsert"
161
  }
162

163
  private struct UploadResponse: Decodable {
164
    let Key: String
165
    let Id: UUID
166
  }
167

168
  private struct SignedUploadResponse: Decodable {
169
    let Key: String
170
  }
171

172
  private func _uploadOrUpdate(
173
    method: HTTPMethod,
174
    path: String,
175
    file: FileUpload,
176
    options: FileOptions?,
177
    progress: (@Sendable (UploadProgress) -> Void)? = nil
178
  ) async throws -> FileUploadResponse {
5✔
179
    let options = options ?? defaultFileOptions
5✔
180
    let cleanPath = _removeEmptyFolders(path)
5✔
181
    let _path = _getFinalPath(cleanPath)
5✔
182

5✔
183
    var headers = multipartHeaders(options: options)
5✔
184
    headers[Header.xUpsert] = "\(options.upsert)"
5✔
185

5✔
186
    let response: UploadResponse = try await uploadMultipart(
5✔
187
      method,
5✔
188
      url: client.url.appendingPathComponent("object/\(_path)"),
5✔
189
      path: path,
5✔
190
      file: file,
5✔
191
      options: options,
5✔
192
      headers: headers,
5✔
193
      progress: progress
5✔
194
    )
5✔
195

5✔
196
    return FileUploadResponse(
5✔
197
      id: response.Id,
5✔
198
      path: path,
5✔
199
      fullPath: response.Key
5✔
200
    )
5✔
201
  }
5✔
202

203
  /// Uploads a `Data` value to an existing bucket.
204
  ///
205
  /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error
206
  /// is returned. Set `upsert: true` to overwrite silently instead.
207
  ///
208
  /// - Parameters:
209
  ///   - path: The destination path within the bucket, e.g. `"folder/image.png"`.
210
  ///     The bucket must already exist.
211
  ///   - data: The raw file bytes to store.
212
  ///   - options: Upload options such as content type, cache duration, and upsert behaviour.
213
  /// - Returns: A ``FileUploadResponse`` containing the assigned storage ID and full path.
214
  /// - Throws: ``StorageError`` if the path already exists (when `upsert` is `false`), the
215
  ///   bucket does not exist, or the request otherwise fails.
216
  ///
217
  /// ## Example
218
  ///
219
  /// ```swift
220
  /// let imageData = UIImage(named: "photo")!.jpegData(compressionQuality: 0.8)!
221
  /// let response = try await storage.from("avatars").upload(
222
  ///   "user-123/photo.jpg",
223
  ///   data: imageData,
224
  ///   options: FileOptions(contentType: "image/jpeg", upsert: true)
225
  /// )
226
  /// print(response.fullPath) // "avatars/user-123/photo.jpg"
227
  /// ```
228
  @discardableResult
229
  public func upload(
230
    _ path: String,
231
    data: Data,
232
    options: FileOptions = FileOptions(),
233
    progress: (@Sendable (UploadProgress) -> Void)? = nil
234
  ) async throws -> FileUploadResponse {
2✔
235
    try await _uploadOrUpdate(
2✔
236
      method: .post,
2✔
237
      path: path,
2✔
238
      file: .data(data),
2✔
239
      options: options,
2✔
240
      progress: progress
2✔
241
    )
2✔
242
  }
2✔
243

244
  /// Uploads a file from a local `URL` to an existing bucket.
245
  ///
246
  /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid
247
  /// loading the entire payload into memory.
248
  ///
249
  /// - Parameters:
250
  ///   - path: The destination path within the bucket, e.g. `"folder/image.png"`.
251
  ///     The bucket must already exist.
252
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
253
  ///   - options: Upload options such as content type, cache duration, and upsert behaviour.
254
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
255
  /// - Returns: A ``FileUploadResponse`` containing the assigned storage ID and full path.
256
  /// - Throws: ``StorageError`` if the path already exists (when `upsert` is `false`), the
257
  ///   bucket does not exist, or the request otherwise fails.
258
  ///
259
  /// ## Example
260
  ///
261
  /// ```swift
262
  /// let fileURL = URL(fileURLWithPath: "/tmp/report.pdf")
263
  /// let response = try await storage.from("documents").upload(
264
  ///   "reports/2024/annual.pdf",
265
  ///   fileURL: fileURL
266
  /// )
267
  /// ```
268
  @discardableResult
269
  public func upload(
270
    _ path: String,
271
    fileURL: URL,
272
    options: FileOptions = FileOptions(),
273
    progress: (@Sendable (UploadProgress) -> Void)? = nil
274
  ) async throws -> FileUploadResponse {
1✔
275
    try await _uploadOrUpdate(
1✔
276
      method: .post,
1✔
277
      path: path,
1✔
278
      file: .url(fileURL),
1✔
279
      options: options,
1✔
280
      progress: progress
1✔
281
    )
1✔
282
  }
1✔
283

284
  /// Replaces an existing file at the specified path with new `Data`.
285
  ///
286
  /// Unlike ``upload(_:data:options:)``, this method always overwrites the existing object and
287
  /// returns an error if no object exists at the given path.
288
  ///
289
  /// - Parameters:
290
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
291
  ///     The bucket must already exist.
292
  ///   - data: The new raw file bytes.
293
  ///   - options: Upload options such as content type and cache duration.
294
  /// - Returns: A ``FileUploadResponse`` containing the storage ID and full path of the updated object.
295
  /// - Throws: ``StorageError`` if the path does not exist or the request fails.
296
  ///
297
  /// ## Example
298
  ///
299
  /// ```swift
300
  /// let newImageData = UIImage(named: "updated-photo")!.pngData()!
301
  /// let response = try await storage.from("avatars").update(
302
  ///   "user-123/photo.png",
303
  ///   data: newImageData,
304
  ///   options: FileOptions(contentType: "image/png")
305
  /// )
306
  /// ```
307
  @discardableResult
308
  public func update(
309
    _ path: String,
310
    data: Data,
311
    options: FileOptions = FileOptions(),
312
    progress: (@Sendable (UploadProgress) -> Void)? = nil
313
  ) async throws -> FileUploadResponse {
1✔
314
    try await _uploadOrUpdate(
1✔
315
      method: .put,
1✔
316
      path: path,
1✔
317
      file: .data(data),
1✔
318
      options: options,
1✔
319
      progress: progress
1✔
320
    )
1✔
321
  }
1✔
322

323
  /// Replaces an existing file at the specified path with the contents of a local `URL`.
324
  ///
325
  /// Unlike ``upload(_:fileURL:options:)``, this method always overwrites the existing object and
326
  /// returns an error if no object exists at the given path.
327
  ///
328
  /// - Parameters:
329
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
330
  ///     The bucket must already exist.
331
  ///   - fileURL: A local `file://` URL pointing to the replacement file.
332
  ///   - options: Upload options such as content type and cache duration.
333
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
334
  /// - Returns: A ``FileUploadResponse`` containing the storage ID and full path of the updated object.
335
  /// - Throws: ``StorageError`` if the path does not exist or the request fails.
336
  ///
337
  /// ## Example
338
  ///
339
  /// ```swift
340
  /// let newFileURL = URL(fileURLWithPath: "/tmp/updated-report.pdf")
341
  /// try await storage.from("documents").update("reports/2024/annual.pdf", fileURL: newFileURL)
342
  /// ```
343
  @discardableResult
344
  public func update(
345
    _ path: String,
346
    fileURL: URL,
347
    options: FileOptions = FileOptions(),
348
    progress: (@Sendable (UploadProgress) -> Void)? = nil
349
  ) async throws -> FileUploadResponse {
1✔
350
    try await _uploadOrUpdate(
1✔
351
      method: .put,
1✔
352
      path: path,
1✔
353
      file: .url(fileURL),
1✔
354
      options: options,
1✔
355
      progress: progress
1✔
356
    )
1✔
357
  }
1✔
358

359
  /// Moves an existing file to a new path within the same or a different bucket.
360
  ///
361
  /// The source file is removed after the move completes. To keep the original, use ``copy(from:to:options:)`` instead.
362
  ///
363
  /// - Parameters:
364
  ///   - source: The current file path, e.g. `"folder/image.png"`.
365
  ///   - destination: The new file path, e.g. `"archive/image.png"`.
366
  ///   - options: Optional destination overrides, such as moving the file into a different bucket.
367
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
368
  ///
369
  /// ## Example
370
  ///
371
  /// ```swift
372
  /// // Rename within the same bucket
373
  /// try await storage.from("avatars").move(from: "old-name.png", to: "new-name.png")
374
  ///
375
  /// // Move to a different bucket
376
  /// try await storage.from("avatars").move(
377
  ///   from: "user-123/photo.png",
378
  ///   to: "user-123/photo.png",
379
  ///   options: DestinationOptions(destinationBucket: "archive")
380
  /// )
381
  /// ```
382
  public func move(
383
    from source: String,
384
    to destination: String,
385
    options: DestinationOptions? = nil
386
  ) async throws {
3✔
387
    let body: [String: String?] = [
3✔
388
      "bucketId": bucketId,
3✔
389
      "sourceKey": source,
3✔
390
      "destinationKey": destination,
3✔
391
      "destinationBucket": options?.destinationBucket,
3✔
392
    ]
3✔
393

3✔
394
    try await client.fetchData(
3✔
395
      .post,
3✔
396
      "object/move",
3✔
397
      body: .data(client.encoder.encode(body))
3✔
398
    )
3✔
399
  }
1✔
400

401
  /// Copies an existing file to a new path, leaving the original in place.
402
  ///
403
  /// To move a file without keeping the original, use ``move(from:to:options:)`` instead.
404
  ///
405
  /// - Parameters:
406
  ///   - source: The current file path, e.g. `"folder/image.png"`.
407
  ///   - destination: The path for the copy, e.g. `"folder/image-copy.png"`.
408
  ///   - options: Optional destination overrides, such as copying the file into a different bucket.
409
  /// - Returns: The full storage key of the newly created copy.
410
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
411
  ///
412
  /// ## Example
413
  ///
414
  /// ```swift
415
  /// // Duplicate a file in the same bucket
416
  /// let key = try await storage.from("avatars").copy(
417
  ///   from: "user-123/photo.png",
418
  ///   to: "user-123/photo-backup.png"
419
  /// )
420
  /// print(key) // "avatars/user-123/photo-backup.png"
421
  /// ```
422
  @discardableResult
423
  public func copy(
424
    from source: String,
425
    to destination: String,
426
    options: DestinationOptions? = nil
427
  ) async throws -> String {
1✔
428
    struct UploadResponse: Decodable {
1✔
429
      let Key: String
1✔
430
    }
1✔
431

1✔
432
    let body: [String: String?] = [
1✔
433
      "bucketId": bucketId,
1✔
434
      "sourceKey": source,
1✔
435
      "destinationKey": destination,
1✔
436
      "destinationBucket": options?.destinationBucket,
1✔
437
    ]
1✔
438

1✔
439
    let response: UploadResponse = try await client.fetchDecoded(
1✔
440
      .post,
1✔
441
      "object/copy",
1✔
442
      body: .data(client.encoder.encode(body))
1✔
443
    )
1✔
444

1✔
445
    return response.Key
1✔
446
  }
1✔
447

448
  /// Creates a signed URL that grants time-limited access to a private file.
449
  ///
450
  /// - Parameters:
451
  ///   - path: The file path within the bucket, e.g. `"folder/image.png"`.
452
  ///   - expiresIn: How long until the signed URL expires, e.g. `.seconds(3600)`.
453
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
454
  ///   - transform: Optional on-the-fly image transformation applied before the file is served.
455
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
456
  /// - Returns: A signed `URL` ready to be shared or embedded.
457
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
458
  public func createSignedURL(
459
    path: String,
460
    expiresIn: Duration,
461
    download: DownloadBehavior? = nil,
462
    transform: TransformOptions? = nil,
463
    cacheNonce: String? = nil
464
  ) async throws -> URL {
3✔
465
    struct Body: Encodable {
3✔
466
      let expiresIn: Int
3✔
467
      let transform: TransformOptions?
3✔
468
    }
3✔
469

3✔
470
    let response: SignedURLAPIResponse = try await client.fetchDecoded(
3✔
471
      .post,
3✔
472
      "object/sign/\(bucketId)/\(path)",
3✔
473
      body: .data(
3✔
474
        originalEncoder.encode(
3✔
475
          Body(
3✔
476
            expiresIn: Int(expiresIn.components.seconds),
3✔
477
            transform: transform
3✔
478
          )
3✔
479
        )
3✔
480
      )
3✔
481
    )
3✔
482

3✔
483
    return try makeSignedURL(
3✔
484
      response.signedURL,
3✔
485
      download: download,
3✔
486
      cacheNonce: cacheNonce
3✔
487
    )
3✔
488
  }
3✔
489

490
  /// Creates signed URLs for multiple files in a single request.
491
  ///
492
  /// - Parameters:
493
  ///   - paths: The file paths within the bucket.
494
  ///   - expiresIn: How long until the signed URLs expire.
495
  ///   - download: When non-`nil`, the browser treats each URL as a file download.
496
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
497
  /// - Returns: An array of ``SignedURLResult`` values, one per input path.
498
  /// - Throws: ``StorageError`` if the batch request itself fails.
499
  public func createSignedURLs(
500
    paths: [String],
501
    expiresIn: Duration,
502
    download: DownloadBehavior? = nil,
503
    cacheNonce: String? = nil
504
  ) async throws -> [SignedURLResult] {
5✔
505
    struct Params: Encodable {
5✔
506
      let expiresIn: Int
5✔
507
      let paths: [String]
5✔
508
    }
5✔
509

5✔
510
    let response: [SignedURLsAPIResponse] = try await client.fetchDecoded(
5✔
511
      .post,
5✔
512
      "object/sign/\(bucketId)",
5✔
513
      body: .data(
5✔
514
        originalEncoder.encode(
5✔
515
          Params(expiresIn: Int(expiresIn.components.seconds), paths: paths)
5✔
516
        )
5✔
517
      )
5✔
518
    )
5✔
519

5✔
520
    return try response.map { item in
9✔
521
      if let signedURLString = item.signedURL {
9✔
522
        let url = try makeSignedURL(
8✔
523
          signedURLString,
8✔
524
          download: download,
8✔
525
          cacheNonce: cacheNonce
8✔
526
        )
8✔
527
        return .success(path: item.path, signedURL: url)
8✔
528
      } else {
8✔
529
        return .failure(path: item.path, error: item.error ?? "Unknown error")
1✔
530
      }
1✔
531
    }
9✔
532
  }
5✔
533

534
  private func makeSignedURL(
535
    _ signedURL: String,
536
    download: DownloadBehavior?,
537
    cacheNonce: String? = nil
538
  ) throws -> URL {
13✔
539
    guard let signedURLComponents = URLComponents(string: signedURL),
13✔
540
      var baseComponents = URLComponents(
13✔
541
        url: client.url,
13✔
542
        resolvingAgainstBaseURL: false
13✔
543
      )
13✔
544
    else {
13✔
NEW
545
      throw URLError(.badURL)
×
546
    }
13✔
547

13✔
548
    baseComponents.path +=
13✔
549
      signedURLComponents.path.hasPrefix("/")
13✔
550
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
551
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
552

13✔
553
    if let download {
13✔
554
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
555
      let value: String
3✔
556
      switch download {
3✔
557
      case .withOriginalName: value = ""
3✔
558
      case .named(let name): value = name
3✔
559
      }
3✔
560
      baseComponents.queryItems!.append(
3✔
561
        URLQueryItem(name: "download", value: value)
3✔
562
      )
3✔
563
    }
13✔
564

13✔
565
    if let cacheNonce {
13✔
566
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
567
      baseComponents.queryItems!.append(
2✔
568
        URLQueryItem(name: "cacheNonce", value: cacheNonce)
2✔
569
      )
2✔
570
    }
2✔
571

13✔
572
    guard let url = baseComponents.url else {
13✔
NEW
573
      throw URLError(.badURL)
×
574
    }
13✔
575
    return url
13✔
576
  }
13✔
577

578
  /// Deletes one or more files from the bucket.
579
  ///
580
  /// All listed paths are deleted in a single request. Non-existent paths are silently ignored.
581
  ///
582
  /// - Parameter paths: The paths of the files to delete, e.g. `["folder/image.png", "other.pdf"]`.
583
  /// - Returns: An array of ``FileObject`` values describing the deleted files.
584
  /// - Throws: ``StorageError`` if the request fails.
585
  ///
586
  /// ## Example
587
  ///
588
  /// ```swift
589
  /// let deleted = try await storage.from("avatars").remove(paths: [
590
  ///   "user-123/photo.png",
591
  ///   "user-456/photo.png"
592
  /// ])
593
  /// print("Deleted \(deleted.count) file(s)")
594
  /// ```
595
  @discardableResult
596
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
597
    try await client.fetchDecoded(
1✔
598
      .delete,
1✔
599
      "object/\(bucketId)",
1✔
600
      body: .data(client.encoder.encode(["prefixes": paths]))
1✔
601
    )
1✔
602
  }
1✔
603

604
  /// Lists files and folders within the bucket, optionally scoped to a path prefix.
605
  ///
606
  /// Results include both files and virtual folder entries. Paginate large buckets using
607
  /// ``SearchOptions/limit`` and ``SearchOptions/offset``.
608
  ///
609
  /// - Parameters:
610
  ///   - path: An optional folder path to list. When `nil`, lists the bucket root.
611
  ///   - options: Filtering, sorting, and pagination options. Defaults to 100 items sorted
612
  ///     by name ascending when `nil`.
613
  /// - Returns: An array of ``FileObject`` values representing the matching files and folders.
614
  /// - Throws: ``StorageError`` if the request fails.
615
  ///
616
  /// ## Example
617
  ///
618
  /// ```swift
619
  /// // List all files in the "user-123" folder
620
  /// let files = try await storage.from("avatars").list(path: "user-123")
621
  ///
622
  /// // Paginate with custom sort
623
  /// let page2 = try await storage.from("photos").list(
624
  ///   path: "gallery",
625
  ///   options: SearchOptions(limit: 20, offset: 20, sortBy: SortBy(column: "created_at", order: .descending))
626
  /// )
627
  /// ```
628
  public func list(
629
    path: String? = nil,
630
    options: SearchOptions? = nil
631
  ) async throws -> [FileObject] {
1✔
632
    var options = options ?? defaultSearchOptions
1✔
633
    options.prefix = path ?? ""
1✔
634

1✔
635
    return try await client.fetchDecoded(
1✔
636
      .post,
1✔
637
      "object/list/\(bucketId)",
1✔
638
      body: .data(originalEncoder.encode(options))
1✔
639
    )
1✔
640
  }
1✔
641

642
  /// Downloads a file from the bucket and returns its raw bytes.
643
  ///
644
  /// Use this method for files in private buckets. For public buckets, construct a URL with
645
  /// ``getPublicURL(path:download:options:cacheNonce:)`` and fetch it directly instead —
646
  /// it avoids routing the bytes through the SDK.
647
  ///
648
  /// - Parameters:
649
  ///   - path: The path of the file to download, e.g. `"folder/image.png"`.
650
  ///   - options: Optional on-the-fly image transformation applied before the file is served.
651
  ///     When non-`nil` and non-empty, the request is routed through the image rendering pipeline.
652
  ///   - additionalQueryItems: Extra URL query parameters appended to the download request.
653
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter for cache
654
  ///     invalidation.
655
  /// - Returns: The raw file data.
656
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
657
  ///
658
  /// ## Example
659
  ///
660
  /// ```swift
661
  /// // Download raw file
662
  /// let data = try await storage.from("private-docs").download(path: "user-123/report.pdf")
663
  ///
664
  /// // Download with image transformation
665
  /// let thumbnail = try await storage.from("photos").download(
666
  ///   path: "gallery/hero.jpg",
667
  ///   options: TransformOptions(width: 100, height: 100, resize: .cover)
668
  /// )
669
  /// ```
670
  @discardableResult
671
  public func download(
672
    path: String,
673
    options: TransformOptions? = nil,
674
    query additionalQueryItems: [URLQueryItem]? = nil,
675
    cacheNonce: String? = nil
676
  ) async throws -> Data {
5✔
677
    var queryItems = options?.queryItems ?? []
5✔
678
    let renderPath =
5✔
679
      options.map { !$0.isEmpty } == true
5✔
680
      ? "render/image/authenticated" : "object"
5✔
681
    let _path = _getFinalPath(path)
5✔
682

5✔
683
    if let additionalQueryItems {
5✔
684
      queryItems.append(contentsOf: additionalQueryItems)
1✔
685
    }
1✔
686

5✔
687
    if let cacheNonce {
5✔
688
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
689
    }
1✔
690

5✔
691
    let (data, _) = try await client.fetchData(
5✔
692
      .get,
5✔
693
      url: storageURL(path: "\(renderPath)/\(_path)", queryItems: queryItems)
5✔
694
    )
5✔
695
    return data
5✔
696
  }
5✔
697

698
  /// Retrieves extended metadata for a file without downloading its contents.
699
  ///
700
  /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other
701
  /// server-side metadata. To check only whether a file exists, prefer ``exists(path:)``.
702
  ///
703
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
704
  /// - Returns: A ``FileInfo`` containing the file's metadata.
705
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
706
  ///
707
  /// ## Example
708
  ///
709
  /// ```swift
710
  /// let info = try await storage.from("avatars").info(path: "user-123/photo.png")
711
  /// print("Size: \(info.size ?? 0) bytes, type: \(info.contentType ?? "unknown")")
712
  /// ```
713
  public func info(path: String) async throws -> FileInfo {
1✔
714
    let _path = _getFinalPath(path)
1✔
715

1✔
716
    return try await client.fetchDecoded(.get, "object/info/\(_path)")
1✔
717
  }
1✔
718

719
  /// Checks whether a file exists in the bucket.
720
  ///
721
  /// Issues a `HEAD` request and returns `true` if the server responds with a success status,
722
  /// `false` if the server returns 400 or 404. Any other error is re-thrown.
723
  ///
724
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
725
  /// - Returns: `true` if the file exists, `false` if it does not.
726
  /// - Throws: ``StorageError`` for server errors other than 400/404.
727
  ///
728
  /// ## Example
729
  ///
730
  /// ```swift
731
  /// if try await storage.from("avatars").exists(path: "user-123/photo.png") {
732
  ///   print("File found")
733
  /// } else {
734
  ///   print("File not found")
735
  /// }
736
  /// ```
737
  public func exists(path: String) async throws -> Bool {
3✔
738
    do {
3✔
739
      try await client.fetchData(.head, "object/\(bucketId)/\(path)")
3✔
740
      return true
1✔
741
    } catch let error as StorageError
3✔
742
      where error.isNotFound || error.statusCode == 400
3✔
743
    {
3✔
744
      // The Storage server returns 400 (instead of 404) for HEAD requests on non-existent objects.
2✔
745
      return false
2✔
746
    }
2✔
747
  }
3✔
748

749
  /// Returns the public URL for a file in a public bucket.
750
  ///
751
  /// The URL is constructed locally without a network request.
752
  ///
753
  /// - Parameters:
754
  ///   - path: The path of the file within the bucket.
755
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
756
  ///   - options: Optional on-the-fly image transformation.
757
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
758
  /// - Returns: The public `URL` for the file.
759
  /// - Throws: `URLError(.badURL)` if the resulting URL cannot be constructed.
760
  public func getPublicURL(
761
    path: String,
762
    download: DownloadBehavior? = nil,
763
    options: TransformOptions? = nil,
764
    cacheNonce: String? = nil
765
  ) throws -> URL {
7✔
766
    var queryItems: [URLQueryItem] = []
7✔
767

7✔
768
    guard
7✔
769
      var components = URLComponents(
7✔
770
        url: client.url,
7✔
771
        resolvingAgainstBaseURL: true
7✔
772
      )
7✔
773
    else {
7✔
NEW
774
      throw URLError(.badURL)
×
775
    }
7✔
776

7✔
777
    if let download {
7✔
778
      let value: String
3✔
779
      switch download {
3✔
780
      case .withOriginalName: value = ""
3✔
781
      case .named(let name): value = name
3✔
782
      }
3✔
783
      queryItems.append(URLQueryItem(name: "download", value: value))
3✔
784
    }
7✔
785

7✔
786
    if let optionsQueryItems = options?.queryItems {
7✔
787
      queryItems.append(contentsOf: optionsQueryItems)
3✔
788
    }
3✔
789

7✔
790
    if let cacheNonce {
7✔
791
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
792
    }
1✔
793

7✔
794
    let renderPath =
7✔
795
      options.map { !$0.isEmpty } == true ? "render/image" : "object"
7✔
796
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
7✔
797
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
7✔
798

7✔
799
    guard let generatedUrl = components.url else {
7✔
NEW
800
      throw URLError(.badURL)
×
801
    }
7✔
802
    return generatedUrl
7✔
803
  }
7✔
804

805
  /// Creates a signed upload URL for uploading a file without further authentication.
806
  ///
807
  /// Signed upload URLs are valid for **2 hours** and allow anyone in possession of the URL to
808
  /// upload a file to the specified path. This is useful for client-side uploads where you do not
809
  /// want to expose Storage credentials.
810
  ///
811
  /// After calling this method, pass the returned ``SignedUploadURL/token`` to
812
  /// ``uploadToSignedURL(_:token:data:options:)`` or
813
  /// ``uploadToSignedURL(_:token:fileURL:options:)`` to perform the actual upload.
814
  ///
815
  /// - Parameters:
816
  ///   - path: The destination path within the bucket, e.g. `"user-123/upload.png"`.
817
  ///   - options: Optional overrides, such as enabling upsert behaviour.
818
  /// - Returns: A ``SignedUploadURL`` containing the signed URL, path, and extracted token.
819
  /// - Throws: ``StorageError`` if the request fails.
820
  ///
821
  /// ## Example
822
  ///
823
  /// ```swift
824
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/photo.png")
825
  ///
826
  /// // Later, upload the file using the signed token
827
  /// try await storage.from("uploads").uploadToSignedURL(
828
  ///   signed.path,
829
  ///   token: signed.token,
830
  ///   data: imageData,
831
  ///   options: FileOptions(contentType: "image/png")
832
  /// )
833
  /// ```
834
  public func createSignedUploadURL(
835
    path: String,
836
    options: CreateSignedUploadURLOptions? = nil
837
  ) async throws -> SignedUploadURL {
2✔
838
    struct Response: Decodable {
2✔
839
      let url: String
2✔
840
    }
2✔
841

2✔
842
    var headers = [String: String]()
2✔
843
    if let upsert = options?.upsert, upsert {
2✔
844
      headers[Header.xUpsert] = "true"
1✔
845
    }
1✔
846

2✔
847
    let response: Response = try await client.fetchDecoded(
2✔
848
      .post,
2✔
849
      "object/upload/sign/\(bucketId)/\(path)",
2✔
850
      headers: headers
2✔
851
    )
2✔
852

2✔
853
    let signedURL = try makeSignedURL(response.url, download: nil)
2✔
854

2✔
855
    guard
2✔
856
      let components = URLComponents(
2✔
857
        url: signedURL,
2✔
858
        resolvingAgainstBaseURL: false
2✔
859
      )
2✔
860
    else {
2✔
NEW
861
      throw URLError(.badURL)
×
862
    }
2✔
863

2✔
864
    guard
2✔
865
      let token = components.queryItems?.first(where: { $0.name == "token" })?
2✔
866
        .value
2✔
867
    else {
2✔
NEW
868
      throw StorageError.noTokenReturned
×
869
    }
2✔
870

2✔
871
    guard let url = components.url else {
2✔
NEW
872
      throw URLError(.badURL)
×
873
    }
2✔
874

2✔
875
    return SignedUploadURL(
2✔
876
      signedURL: url,
2✔
877
      path: path,
2✔
878
      token: token
2✔
879
    )
2✔
880
  }
2✔
881

882
  /// Uploads `Data` to a path using a token obtained from ``createSignedUploadURL(path:options:)``.
883
  ///
884
  /// The token must match the path and must not have expired (signed upload URLs are valid for
885
  /// 2 hours).
886
  ///
887
  /// - Parameters:
888
  ///   - path: The destination path within the bucket, matching the path used when the token
889
  ///     was created.
890
  ///   - token: The upload token from ``SignedUploadURL/token``.
891
  ///   - data: The raw file bytes to store.
892
  ///   - options: Upload options such as content type and cache duration.
893
  /// - Returns: A ``SignedURLUploadResponse`` containing the path and full storage key.
894
  /// - Throws: ``StorageError`` if the token is invalid or expired, or if the request fails.
895
  ///
896
  /// ## Example
897
  ///
898
  /// ```swift
899
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "file.pdf")
900
  /// let response = try await storage.from("uploads").uploadToSignedURL(
901
  ///   signed.path,
902
  ///   token: signed.token,
903
  ///   data: pdfData,
904
  ///   options: FileOptions(contentType: "application/pdf")
905
  /// )
906
  /// print(response.fullPath)
907
  /// ```
908
  @discardableResult
909
  public func uploadToSignedURL(
910
    _ path: String,
911
    token: String,
912
    data: Data,
913
    options: FileOptions = FileOptions(),
914
    progress: (@Sendable (UploadProgress) -> Void)? = nil
915
  ) async throws -> SignedURLUploadResponse {
1✔
916
    try await _uploadToSignedURL(
1✔
917
      path: path,
1✔
918
      token: token,
1✔
919
      file: .data(data),
1✔
920
      options: options,
1✔
921
      progress: progress
1✔
922
    )
1✔
923
  }
1✔
924

925
  /// Uploads a file from a local `URL` to a path using a token obtained from
926
  /// ``createSignedUploadURL(path:options:)``.
927
  ///
928
  /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid
929
  /// loading the entire payload into memory.
930
  ///
931
  /// - Parameters:
932
  ///   - path: The destination path within the bucket, matching the path used when the token
933
  ///     was created.
934
  ///   - token: The upload token from ``SignedUploadURL/token``.
935
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
936
  ///   - options: Upload options such as content type and cache duration.
937
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
938
  /// - Returns: A ``SignedURLUploadResponse`` containing the path and full storage key.
939
  /// - Throws: ``StorageError`` if the token is invalid or expired, or if the request fails.
940
  ///
941
  /// ## Example
942
  ///
943
  /// ```swift
944
  /// let fileURL = URL(fileURLWithPath: "/tmp/video.mp4")
945
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/video.mp4")
946
  /// let response = try await storage.from("uploads").uploadToSignedURL(
947
  ///   signed.path,
948
  ///   token: signed.token,
949
  ///   fileURL: fileURL
950
  /// )
951
  /// ```
952
  @discardableResult
953
  public func uploadToSignedURL(
954
    _ path: String,
955
    token: String,
956
    fileURL: URL,
957
    options: FileOptions = FileOptions(),
958
    progress: (@Sendable (UploadProgress) -> Void)? = nil
959
  ) async throws -> SignedURLUploadResponse {
2✔
960
    try await _uploadToSignedURL(
2✔
961
      path: path,
2✔
962
      token: token,
2✔
963
      file: .url(fileURL),
2✔
964
      options: options,
2✔
965
      progress: progress
2✔
966
    )
2✔
967
  }
2✔
968

969
  private func _uploadToSignedURL(
970
    path: String,
971
    token: String,
972
    file: FileUpload,
973
    options: FileOptions,
974
    progress: (@Sendable (UploadProgress) -> Void)? = nil
975
  ) async throws -> SignedURLUploadResponse {
3✔
976
    var headers = multipartHeaders(options: options)
3✔
977
    headers[Header.xUpsert] = "\(options.upsert)"
3✔
978

3✔
979
    let response: SignedUploadResponse = try await uploadMultipart(
3✔
980
      .put,
3✔
981
      url: storageURL(
3✔
982
        path: "object/upload/sign/\(bucketId)/\(path)",
3✔
983
        queryItems: [URLQueryItem(name: "token", value: token)]
3✔
984
      ),
3✔
985
      path: path,
3✔
986
      file: file,
3✔
987
      options: options,
3✔
988
      headers: headers,
3✔
989
      progress: progress
3✔
990
    )
3✔
991

3✔
992
    return SignedURLUploadResponse(path: path, fullPath: response.Key)
3✔
993
  }
3✔
994

995
  private func uploadMultipart<Response: Decodable>(
996
    _ method: HTTPMethod,
997
    url: URL,
998
    path: String,
999
    file: FileUpload,
1000
    options: FileOptions,
1001
    headers: [String: String],
1002
    progress: (@Sendable (UploadProgress) -> Void)? = nil
1003
  ) async throws -> Response {
8✔
1004
    #if DEBUG
1005
      let builder = MultipartBuilder(
8✔
1006
        boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)"
8✔
1007
      )
8✔
1008
    #else
1009
      let builder = MultipartBuilder()
1010
    #endif
1011

8✔
1012
    let multipart = file.append(
8✔
1013
      to: builder,
8✔
1014
      withPath: path,
8✔
1015
      options: options
8✔
1016
    )
8✔
1017

8✔
1018
    var headers = headers
8✔
1019
    headers[Header.contentType] = multipart.contentType
8✔
1020

8✔
1021
    let request = try await client.http.createRequest(
8✔
1022
      method,
8✔
1023
      url: url,
8✔
1024
      headers: client.mergedHeaders(headers)
8✔
1025
    )
8✔
1026

8✔
1027
    let delegate = progress.map { UploadProgressDelegate(onProgress: $0) }
8✔
1028

8✔
1029
    do {
8✔
1030
      client.logRequest(method, url: url)
8✔
1031
      let data: Data
8✔
1032
      let response: URLResponse
8✔
1033

8✔
1034
      if try file.usesTempFileUpload {
8✔
NEW
1035
        let tempFile = try multipart.buildToTempFile()
×
NEW
1036
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
NEW
1037

×
NEW
1038
        (data, response) = try await client.http.session.upload(
×
NEW
1039
          for: request,
×
NEW
1040
          fromFile: tempFile,
×
NEW
1041
          delegate: delegate
×
NEW
1042
        )
×
1043
      } else {
8✔
1044
        (data, response) = try await client.http.session.upload(
8✔
1045
          for: request,
8✔
1046
          from: try multipart.buildInMemory(),
8✔
1047
          delegate: delegate
8✔
1048
        )
8✔
1049
      }
8✔
1050

8✔
1051
      let httpResponse = try client.http.validateResponse(response, data: data)
8✔
1052
      client.logResponse(httpResponse, data: data)
8✔
1053
      return try client.decoder.decode(Response.self, from: data)
8✔
1054
    } catch {
8✔
NEW
1055
      client.logFailure(error)
×
NEW
1056
      throw client.translateStorageError(error)
×
NEW
1057
    }
×
1058
  }
8✔
1059

1060
  private func multipartHeaders(options: FileOptions) -> [String: String] {
8✔
1061
    var headers: [String: String] = [:]
8✔
1062
    headers.setIfMissing(
8✔
1063
      Header.cacheControl,
8✔
1064
      value: "max-age=\(options.cacheControl)"
8✔
1065
    )
8✔
1066
    return headers
8✔
1067
  }
8✔
1068

1069
  private func storageURL(path: String, queryItems: [URLQueryItem] = []) throws
1070
    -> URL
1071
  {
8✔
1072
    var components = URLComponents(
8✔
1073
      url: client.url.appendingPathComponent(path),
8✔
1074
      resolvingAgainstBaseURL: false
8✔
1075
    )
8✔
1076
    components?.queryItems = queryItems.isEmpty ? nil : queryItems
8✔
1077

8✔
1078
    guard let url = components?.url else {
8✔
NEW
1079
      throw URLError(.badURL)
×
1080
    }
8✔
1081

8✔
1082
    return url
8✔
1083
  }
8✔
1084

1085
  private func _getFinalPath(_ path: String) -> String {
11✔
1086
    let trimmed = path.hasPrefix("/") ? String(path.dropFirst()) : path
11✔
1087
    return "\(bucketId)/\(trimmed)"
11✔
1088
  }
11✔
1089
}
1090

1091
// MARK: - Upload progress
1092

1093
private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate,
1094
  @unchecked Sendable
1095
{
1096
  private let onProgress: @Sendable (UploadProgress) -> Void
1097

1098
  init(onProgress: @escaping @Sendable (UploadProgress) -> Void) {
1✔
1099
    self.onProgress = onProgress
1✔
1100
  }
1✔
1101

1102
  func urlSession(
1103
    _ session: URLSession,
1104
    task: URLSessionTask,
1105
    didSendBodyData bytesSent: Int64,
1106
    totalBytesSent: Int64,
1107
    totalBytesExpectedToSend: Int64
NEW
1108
  ) {
×
NEW
1109
    onProgress(
×
NEW
1110
      UploadProgress(
×
NEW
1111
        totalBytesSent: totalBytesSent,
×
NEW
1112
        totalBytesExpectedToSend: totalBytesExpectedToSend
×
NEW
1113
      )
×
NEW
1114
    )
×
NEW
1115
  }
×
1116
}
1117

1118
func _removeEmptyFolders(_ path: String) -> String {
5✔
1119
  let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
5✔
1120
  let cleanedPath = trimmedPath.replacingOccurrences(
5✔
1121
    of: "/+",
5✔
1122
    with: "/",
5✔
1123
    options: .regularExpression
5✔
1124
  )
5✔
1125
  return cleanedPath
5✔
1126
}
5✔
1127

1128
extension [String: String] {
1129
  fileprivate mutating func setIfMissing(_ key: String, value: String) {
8✔
1130
    guard
8✔
1131
      keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame })
8✔
1132
        == nil
8✔
1133
    else {
8✔
NEW
1134
      return
×
1135
    }
8✔
1136

8✔
1137
    self[key] = value
8✔
1138
  }
8✔
1139
}
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