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

supabase / supabase-swift / 25489037410

07 May 2026 09:59AM UTC coverage: 80.653% (+0.7%) from 79.987%
25489037410

Pull #992

github

grdsdev
style: apply swift-format to PR2 changed files
Pull Request #992: feat(storage): add TUSUploadEngine and UploadMethod smart routing

317 of 346 new or added lines in 3 files covered. (91.62%)

35 existing lines in 1 file now uncovered.

7637 of 9469 relevant lines covered (80.65%)

29.3 hits per line

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

92.21
/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
#if DEBUG
25
  import ConcurrencyExtras
26
  let testingBoundary = LockIsolated<String?>(nil)
1✔
27
#endif
28

29
/// File operations API for objects stored within a single Supabase Storage bucket.
30
///
31
/// Obtain an instance by calling ``StorageClient/from(_:)`` — do not create one directly.
32
///
33
/// `StorageFileAPI` covers the full lifecycle of objects in a bucket: uploading, updating,
34
/// downloading, listing, deleting, moving, copying, and generating signed or public URLs.
35
///
36
/// ## Basic usage
37
///
38
/// ```swift
39
/// let bucket = supabase.storage.from("avatars")
40
///
41
/// // Upload
42
/// let response = try await bucket.upload("user-123/photo.png", data: imageData)
43
///
44
/// // Download to disk
45
/// let url = try await bucket.download(path: "user-123/photo.png").value
46
/// // Or download into memory
47
/// let data = try await bucket.downloadData(path: "user-123/photo.png").value
48
///
49
/// // Public URL (public bucket)
50
/// let url = try bucket.getPublicURL(path: "user-123/photo.png")
51
///
52
/// // Signed URL (private bucket, valid for 60 seconds)
53
/// let signedURL = try await bucket.createSignedURL(path: "user-123/photo.png", expiresIn: 60)
54
/// ```
55
///
56
/// - Note: All stored properties are immutable, making `StorageFileAPI` safe to share across
57
///   concurrency boundaries.
58
public struct StorageFileAPI: Sendable {
59
  /// The bucket id to operate on.
60
  let bucketId: String
61
  let client: StorageClient
62

63
  /// JSONEncoder with default key strategy used when we want `camelCase` keys instead of the default
64
  /// `snake_case` used in Storage services.
65
  let originalEncoder: JSONEncoder = {
42✔
66
    let encoder = JSONEncoder()
42✔
67
    #if DEBUG
68
      if isTesting {
42✔
69
        encoder.outputFormatting = .sortedKeys
42✔
70
      }
42✔
71
    #endif
72
    return encoder
42✔
73
  }()
42✔
74

75
  init(bucketId: String, client: StorageClient) {
42✔
76
    self.bucketId = bucketId
42✔
77
    self.client = client
42✔
78
  }
42✔
79

80
  private struct MoveResponse: Decodable {
81
    let message: String
82
  }
83

84
  private struct SignedURLAPIResponse: Decodable {
85
    let signedURL: String
86
  }
87

88
  private struct SignedURLsAPIResponse: Decodable {
89
    let signedURL: String?
90
    let path: String
91
    let error: String?
92
  }
93

94
  private enum Header {
95
    static let cacheControl = "Cache-Control"
96
    static let contentType = "Content-Type"
97
    static let xUpsert = "x-upsert"
98
  }
99

100
  private struct SignedUploadResponse: Decodable {
101
    let Key: String
102
  }
103

104
  /// Uploads a `Data` value to an existing bucket.
105
  ///
106
  /// - Parameters:
107
  ///   - path: The destination path within the bucket, e.g. `"folder/image.png"`.
108
  ///     The bucket must already exist.
109
  ///   - data: The raw file bytes to store.
110
  ///   - options: Upload options such as content type, cache duration, and upsert behaviour.
111
  ///   - method: The upload protocol to use. Defaults to ``UploadMethod/auto``, which picks
112
  ///     multipart for files ≤ 6 MB and TUS resumable for larger files.
113
  ///     Pass ``UploadMethod/multipart`` or ``UploadMethod/resumable`` to override.
114
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress,
115
  ///   or paused/resumed/cancelled (TUS only).
116
  ///
117
  /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error
118
  /// is returned. Set `upsert: true` to overwrite silently instead.
119
  ///
120
  /// ## Example
121
  ///
122
  /// ```swift
123
  /// // Auto (default) — picks the right protocol based on size
124
  /// let response = try await storage.from("avatars").upload(
125
  ///   "user-123/photo.jpg",
126
  ///   data: imageData,
127
  ///   options: FileOptions(contentType: "image/jpeg")
128
  /// ).value
129
  ///
130
  /// // Force TUS for a large video with pause/resume support
131
  /// let task = storage.from("videos").upload("clip.mp4", data: videoData, method: .resumable)
132
  /// await task.pause()
133
  /// await task.resume()
134
  /// let response = try await task.value
135
  /// ```
136
  @discardableResult
137
  public func upload(
138
    _ path: String,
139
    data: Data,
140
    options: FileOptions = FileOptions(),
141
    method: UploadMethod = .auto
142
  ) -> StorageUploadTask {
10✔
143
    let useResumable: Bool
10✔
144
    switch method {
10✔
145
    case .auto: useResumable = data.count > client.configuration.tusChunkSize
10✔
146
    case .multipart: useResumable = false
10✔
147
    case .resumable: useResumable = true
10✔
148
    }
10✔
149
    if useResumable {
10✔
150
      return TUSUploadEngine.makeTask(
2✔
151
        bucketId: bucketId, path: path, source: .data(data), options: options, client: client)
2✔
152
    } else {
8✔
153
      return MultipartUploadEngine.makeTask(
8✔
154
        bucketId: bucketId, path: path, source: .data(data), options: options, client: client)
8✔
155
    }
8✔
156
  }
10✔
157

158
  /// Uploads a file from a local `URL` to an existing bucket.
159
  ///
160
  /// - Parameters:
161
  ///   - path: The destination path within the bucket, e.g. `"folder/image.png"`.
162
  ///     The bucket must already exist.
163
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
164
  ///   - options: Upload options such as content type, cache duration, and upsert behaviour.
165
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
166
  ///   - method: The upload protocol to use. Defaults to ``UploadMethod/auto``, which picks
167
  ///     multipart for files ≤ 6 MB and TUS resumable for larger files. When the file size
168
  ///     cannot be determined, ``UploadMethod/auto`` falls back to TUS as a safe default.
169
  ///     Pass ``UploadMethod/multipart`` or ``UploadMethod/resumable`` to override.
170
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress,
171
  ///   or paused/resumed/cancelled (TUS only).
172
  ///
173
  /// ## Example
174
  ///
175
  /// ```swift
176
  /// let response = try await storage.from("documents").upload(
177
  ///   "reports/2024/annual.pdf",
178
  ///   fileURL: fileURL
179
  /// ).value
180
  ///
181
  /// // Explicit TUS for a large video
182
  /// let task = storage.from("videos").upload("tour.mov", fileURL: movURL, method: .resumable)
183
  /// let response = try await task.value
184
  /// ```
185
  @discardableResult
186
  public func upload(
187
    _ path: String,
188
    fileURL: URL,
189
    options: FileOptions = FileOptions(),
190
    method: UploadMethod = .auto
191
  ) -> StorageUploadTask {
2✔
192
    let useResumable: Bool
2✔
193
    switch method {
2✔
194
    case .auto:
2✔
195
      let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? Int.max
1✔
196
      useResumable = size > client.configuration.tusChunkSize
1✔
197
    case .multipart: useResumable = false
2✔
198
    case .resumable: useResumable = true
2✔
199
    }
2✔
200
    if useResumable {
2✔
NEW
UNCOV
201
      return TUSUploadEngine.makeTask(
×
NEW
UNCOV
202
        bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client)
×
203
    } else {
2✔
204
      return MultipartUploadEngine.makeTask(
2✔
205
        bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client)
2✔
206
    }
2✔
207
  }
2✔
208

209
  /// Replaces an existing file at the specified path with new `Data`.
210
  ///
211
  /// Always sets `upsert: true` to overwrite the existing object.
212
  ///
213
  /// - Parameters:
214
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
215
  ///   - data: The new raw file bytes.
216
  ///   - options: Upload options such as content type and cache duration.
217
  ///   - method: The upload protocol to use. Defaults to ``UploadMethod/auto``.
218
  ///     See ``upload(_:data:options:method:)`` for details.
219
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress,
220
  ///   or paused/resumed/cancelled (TUS only).
221
  ///
222
  /// ## Example
223
  ///
224
  /// ```swift
225
  /// let response = try await storage.from("avatars").update(
226
  ///   "user-123/photo.png",
227
  ///   data: newImageData,
228
  ///   options: FileOptions(contentType: "image/png")
229
  /// ).value
230
  /// ```
231
  @discardableResult
232
  public func update(
233
    _ path: String,
234
    data: Data,
235
    options: FileOptions = FileOptions(),
236
    method: UploadMethod = .auto
237
  ) -> StorageUploadTask {
5✔
238
    var upsertOptions = options
5✔
239
    upsertOptions.upsert = true
5✔
240
    return upload(path, data: data, options: upsertOptions, method: method)
5✔
241
  }
5✔
242

243
  /// Replaces an existing file at the specified path with the contents of a local `URL`.
244
  ///
245
  /// Always sets `upsert: true` to overwrite the existing object.
246
  ///
247
  /// - Parameters:
248
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
249
  ///   - fileURL: A local `file://` URL pointing to the replacement file.
250
  ///   - options: Upload options such as content type and cache duration.
251
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
252
  ///   - method: The upload protocol to use. Defaults to ``UploadMethod/auto``.
253
  ///     See ``upload(_:fileURL:options:method:)`` for details.
254
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, observed for progress,
255
  ///   or paused/resumed/cancelled (TUS only).
256
  ///
257
  /// ## Example
258
  ///
259
  /// ```swift
260
  /// try await storage.from("documents").update(
261
  ///   "reports/2024/annual.pdf",
262
  ///   fileURL: newFileURL
263
  /// ).value
264
  /// ```
265
  @discardableResult
266
  public func update(
267
    _ path: String,
268
    fileURL: URL,
269
    options: FileOptions = FileOptions(),
270
    method: UploadMethod = .auto
271
  ) -> StorageUploadTask {
1✔
272
    var upsertOptions = options
1✔
273
    upsertOptions.upsert = true
1✔
274
    return upload(path, fileURL: fileURL, options: upsertOptions, method: method)
1✔
275
  }
1✔
276

277
  /// Moves an existing file to a new path within the same or a different bucket.
278
  ///
279
  /// The source file is removed after the move completes. To keep the original, use ``copy(from:to:options:)`` instead.
280
  ///
281
  /// - Parameters:
282
  ///   - source: The current file path, e.g. `"folder/image.png"`.
283
  ///   - destination: The new file path, e.g. `"archive/image.png"`.
284
  ///   - options: Optional destination overrides, such as moving the file into a different bucket.
285
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
286
  ///
287
  /// ## Example
288
  ///
289
  /// ```swift
290
  /// // Rename within the same bucket
291
  /// try await storage.from("avatars").move(from: "old-name.png", to: "new-name.png")
292
  ///
293
  /// // Move to a different bucket
294
  /// try await storage.from("avatars").move(
295
  ///   from: "user-123/photo.png",
296
  ///   to: "user-123/photo.png",
297
  ///   options: DestinationOptions(destinationBucket: "archive")
298
  /// )
299
  /// ```
300
  public func move(
301
    from source: String,
302
    to destination: String,
303
    options: DestinationOptions? = nil
304
  ) async throws {
3✔
305
    let body: [String: String?] = [
3✔
306
      "bucketId": bucketId,
3✔
307
      "sourceKey": source,
3✔
308
      "destinationKey": destination,
3✔
309
      "destinationBucket": options?.destinationBucket,
3✔
310
    ]
3✔
311

3✔
312
    try await client.fetchData(
3✔
313
      .post,
3✔
314
      "object/move",
3✔
315
      body: .data(client.encoder.encode(body))
3✔
316
    )
3✔
317
  }
1✔
318

319
  /// Copies an existing file to a new path, leaving the original in place.
320
  ///
321
  /// To move a file without keeping the original, use ``move(from:to:options:)`` instead.
322
  ///
323
  /// - Parameters:
324
  ///   - source: The current file path, e.g. `"folder/image.png"`.
325
  ///   - destination: The path for the copy, e.g. `"folder/image-copy.png"`.
326
  ///   - options: Optional destination overrides, such as copying the file into a different bucket.
327
  /// - Returns: The full storage key of the newly created copy.
328
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
329
  ///
330
  /// ## Example
331
  ///
332
  /// ```swift
333
  /// // Duplicate a file in the same bucket
334
  /// let key = try await storage.from("avatars").copy(
335
  ///   from: "user-123/photo.png",
336
  ///   to: "user-123/photo-backup.png"
337
  /// )
338
  /// print(key) // "avatars/user-123/photo-backup.png"
339
  /// ```
340
  @discardableResult
341
  public func copy(
342
    from source: String,
343
    to destination: String,
344
    options: DestinationOptions? = nil
345
  ) async throws -> String {
1✔
346
    struct UploadResponse: Decodable {
1✔
347
      let Key: String
1✔
348
    }
1✔
349

1✔
350
    let body: [String: String?] = [
1✔
351
      "bucketId": bucketId,
1✔
352
      "sourceKey": source,
1✔
353
      "destinationKey": destination,
1✔
354
      "destinationBucket": options?.destinationBucket,
1✔
355
    ]
1✔
356

1✔
357
    let response: UploadResponse = try await client.fetchDecoded(
1✔
358
      .post,
1✔
359
      "object/copy",
1✔
360
      body: .data(client.encoder.encode(body))
1✔
361
    )
1✔
362

1✔
363
    return response.Key
1✔
364
  }
1✔
365

366
  /// Creates a signed URL that grants time-limited access to a private file.
367
  ///
368
  /// Signed URLs can be shared with unauthenticated users. They expire after `expiresIn` and
369
  /// cannot be revoked once issued, so avoid long expiration windows for sensitive files.
370
  ///
371
  /// - Parameters:
372
  ///   - path: The file path within the bucket, e.g. `"folder/image.png"`.
373
  ///   - expiresIn: How long until the signed URL expires, e.g. `.seconds(3600)` for one hour.
374
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
375
  ///   - transform: Optional on-the-fly image transformation applied before the file is served.
376
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
377
  /// - Returns: A signed `URL` ready to be shared or embedded.
378
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
379
  ///
380
  /// ## Example
381
  ///
382
  /// ```swift
383
  /// // Short-lived link for streaming a private video
384
  /// let url = try await storage.from("media")
385
  ///   .createSignedURL(path: "user-123/video.mp4", expiresIn: .seconds(3600))
386
  ///
387
  /// // Signed download link with a custom filename
388
  /// let downloadURL = try await storage.from("reports")
389
  ///   .createSignedURL(
390
  ///     path: "q4-2024.pdf",
391
  ///     expiresIn: .seconds(300),
392
  ///     download: .named("Q4-2024-Report.pdf")
393
  ///   )
394
  /// ```
395
  public func createSignedURL(
396
    path: String,
397
    expiresIn: Duration,
398
    download: DownloadBehavior? = nil,
399
    transform: TransformOptions? = nil,
400
    cacheNonce: String? = nil
401
  ) async throws -> URL {
3✔
402
    struct Body: Encodable {
3✔
403
      let expiresIn: Int
3✔
404
      let transform: TransformOptions?
3✔
405
    }
3✔
406

3✔
407
    let response: SignedURLAPIResponse = try await client.fetchDecoded(
3✔
408
      .post,
3✔
409
      "object/sign/\(bucketId)/\(path)",
3✔
410
      body: .data(
3✔
411
        originalEncoder.encode(
3✔
412
          Body(
3✔
413
            expiresIn: Int(expiresIn.components.seconds),
3✔
414
            transform: transform
3✔
415
          )
3✔
416
        )
3✔
417
      )
3✔
418
    )
3✔
419

3✔
420
    return try makeSignedURL(
3✔
421
      response.signedURL,
3✔
422
      download: download,
3✔
423
      cacheNonce: cacheNonce
3✔
424
    )
3✔
425
  }
3✔
426

427
  /// Creates signed URLs for multiple files in a single request.
428
  ///
429
  /// - Parameters:
430
  ///   - paths: The file paths within the bucket.
431
  ///   - expiresIn: How long until the signed URLs expire.
432
  ///   - download: When non-`nil`, the browser treats each URL as a file download.
433
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
434
  /// - Returns: An array of ``SignedURLResult`` values, one per input path.
435
  /// - Throws: ``StorageError`` if the batch request itself fails.
436
  ///
437
  /// ## Example
438
  ///
439
  /// ```swift
440
  /// let results = try await storage.from("media").createSignedURLs(
441
  ///   paths: ["photo1.jpg", "photo2.jpg", "missing.jpg"],
442
  ///   expiresIn: .seconds(3600)
443
  /// )
444
  ///
445
  /// for result in results {
446
  ///   switch result {
447
  ///   case .success(let path, let url):
448
  ///     print("\(path) → \(url)")
449
  ///   case .failure(let path, let error):
450
  ///     print("\(path) failed: \(error)")
451
  ///   }
452
  /// }
453
  /// ```
454
  public func createSignedURLs(
455
    paths: [String],
456
    expiresIn: Duration,
457
    download: DownloadBehavior? = nil,
458
    cacheNonce: String? = nil
459
  ) async throws -> [SignedURLResult] {
5✔
460
    struct Params: Encodable {
5✔
461
      let expiresIn: Int
5✔
462
      let paths: [String]
5✔
463
    }
5✔
464

5✔
465
    let response: [SignedURLsAPIResponse] = try await client.fetchDecoded(
5✔
466
      .post,
5✔
467
      "object/sign/\(bucketId)",
5✔
468
      body: .data(
5✔
469
        originalEncoder.encode(
5✔
470
          Params(expiresIn: Int(expiresIn.components.seconds), paths: paths)
5✔
471
        )
5✔
472
      )
5✔
473
    )
5✔
474

5✔
475
    return try response.map { item in
9✔
476
      if let signedURLString = item.signedURL {
9✔
477
        let url = try makeSignedURL(
8✔
478
          signedURLString,
8✔
479
          download: download,
8✔
480
          cacheNonce: cacheNonce
8✔
481
        )
8✔
482
        return .success(path: item.path, signedURL: url)
8✔
483
      } else {
8✔
484
        return .failure(path: item.path, error: item.error ?? "Unknown error")
1✔
485
      }
1✔
486
    }
9✔
487
  }
5✔
488

489
  private func makeSignedURL(
490
    _ signedURL: String,
491
    download: DownloadBehavior?,
492
    cacheNonce: String? = nil
493
  ) throws -> URL {
13✔
494
    guard let signedURLComponents = URLComponents(string: signedURL),
13✔
495
      var baseComponents = URLComponents(
13✔
496
        url: client.url,
13✔
497
        resolvingAgainstBaseURL: false
13✔
498
      )
13✔
499
    else {
13✔
UNCOV
500
      throw URLError(.badURL)
×
501
    }
13✔
502

13✔
503
    baseComponents.path +=
13✔
504
      signedURLComponents.path.hasPrefix("/")
13✔
505
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
506
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
507

13✔
508
    if let download {
13✔
509
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
510
      let value: String
3✔
511
      switch download {
3✔
512
      case .withOriginalName: value = ""
3✔
513
      case .named(let name): value = name
3✔
514
      }
3✔
515
      baseComponents.queryItems!.append(
3✔
516
        URLQueryItem(name: "download", value: value)
3✔
517
      )
3✔
518
    }
13✔
519

13✔
520
    if let cacheNonce {
13✔
521
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
522
      baseComponents.queryItems!.append(
2✔
523
        URLQueryItem(name: "cacheNonce", value: cacheNonce)
2✔
524
      )
2✔
525
    }
2✔
526

13✔
527
    guard let url = baseComponents.url else {
13✔
UNCOV
528
      throw URLError(.badURL)
×
529
    }
13✔
530
    return url
13✔
531
  }
13✔
532

533
  /// Deletes one or more files from the bucket.
534
  ///
535
  /// All listed paths are deleted in a single request. Non-existent paths are silently ignored.
536
  ///
537
  /// - Parameter paths: The paths of the files to delete, e.g. `["folder/image.png", "other.pdf"]`.
538
  /// - Returns: An array of ``FileObject`` values describing the deleted files.
539
  /// - Throws: ``StorageError`` if the request fails.
540
  ///
541
  /// ## Example
542
  ///
543
  /// ```swift
544
  /// let deleted = try await storage.from("avatars").remove(paths: [
545
  ///   "user-123/photo.png",
546
  ///   "user-456/photo.png"
547
  /// ])
548
  /// print("Deleted \(deleted.count) file(s)")
549
  /// ```
550
  @discardableResult
551
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
552
    try await client.fetchDecoded(
1✔
553
      .delete,
1✔
554
      "object/\(bucketId)",
1✔
555
      body: .data(client.encoder.encode(["prefixes": paths]))
1✔
556
    )
1✔
557
  }
1✔
558

559
  /// Lists files and folders within the bucket, optionally scoped to a path prefix.
560
  ///
561
  /// Results include both files and virtual folder entries. Paginate large buckets using
562
  /// ``SearchOptions/limit`` and ``SearchOptions/offset``.
563
  ///
564
  /// - Parameters:
565
  ///   - path: An optional folder path to list. When `nil`, lists the bucket root.
566
  ///   - options: Filtering, sorting, and pagination options. Defaults to 100 items sorted
567
  ///     by name ascending when `nil`.
568
  /// - Returns: An array of ``FileObject`` values representing the matching files and folders.
569
  /// - Throws: ``StorageError`` if the request fails.
570
  ///
571
  /// ## Example
572
  ///
573
  /// ```swift
574
  /// // List all files in the "user-123" folder
575
  /// let files = try await storage.from("avatars").list(path: "user-123")
576
  ///
577
  /// // Paginate with custom sort
578
  /// let page2 = try await storage.from("photos").list(
579
  ///   path: "gallery",
580
  ///   options: SearchOptions(limit: 20, offset: 20, sortBy: SortBy(column: "created_at", order: .descending))
581
  /// )
582
  /// ```
583
  public func list(
584
    path: String? = nil,
585
    options: SearchOptions? = nil
586
  ) async throws -> [FileObject] {
1✔
587
    var options = options ?? defaultSearchOptions
1✔
588
    options.prefix = path ?? ""
1✔
589

1✔
590
    return try await client.fetchDecoded(
1✔
591
      .post,
1✔
592
      "object/list/\(bucketId)",
1✔
593
      body: .data(originalEncoder.encode(options))
1✔
594
    )
1✔
595
  }
1✔
596

597
  /// Retrieves extended metadata for a file without downloading its contents.
598
  ///
599
  /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other
600
  /// server-side metadata. To check only whether a file exists, prefer ``exists(path:)``.
601
  ///
602
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
603
  /// - Returns: A ``FileInfo`` containing the file's metadata.
604
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
605
  ///
606
  /// ## Example
607
  ///
608
  /// ```swift
609
  /// let info = try await storage.from("avatars").info(path: "user-123/photo.png")
610
  /// print("Size: \(info.size ?? 0) bytes, type: \(info.contentType ?? "unknown")")
611
  /// ```
612
  public func info(path: String) async throws -> FileInfo {
1✔
613
    let _path = _getFinalPath(path)
1✔
614

1✔
615
    return try await client.fetchDecoded(.get, "object/info/\(_path)")
1✔
616
  }
1✔
617

618
  /// Checks whether a file exists in the bucket.
619
  ///
620
  /// Issues a `HEAD` request and returns `true` if the server responds with a success status,
621
  /// `false` if the server returns 400 or 404. Any other error is re-thrown.
622
  ///
623
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
624
  /// - Returns: `true` if the file exists, `false` if it does not.
625
  /// - Throws: ``StorageError`` for server errors other than 400/404.
626
  ///
627
  /// ## Example
628
  ///
629
  /// ```swift
630
  /// if try await storage.from("avatars").exists(path: "user-123/photo.png") {
631
  ///   print("File found")
632
  /// } else {
633
  ///   print("File not found")
634
  /// }
635
  /// ```
636
  public func exists(path: String) async throws -> Bool {
3✔
637
    do {
3✔
638
      try await client.fetchData(.head, "object/\(bucketId)/\(path)")
3✔
639
      return true
1✔
640
    } catch let error as StorageError
3✔
641
      where error.isNotFound || error.statusCode == 400
3✔
642
    {
3✔
643
      // The Storage server returns 400 (instead of 404) for HEAD requests on non-existent objects.
2✔
644
      return false
2✔
645
    }
2✔
646
  }
3✔
647

648
  /// Returns the public URL for a file in a public bucket.
649
  ///
650
  /// The bucket must be configured as public; for private buckets use
651
  /// ``createSignedURL(path:expiresIn:download:transform:cacheNonce:)`` instead.
652
  ///
653
  /// - Parameters:
654
  ///   - path: The path of the file within the bucket, e.g. `"user-123/avatar.png"`.
655
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
656
  ///   - options: Optional on-the-fly image transformation (resize, reformat, quality).
657
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
658
  /// - Returns: The public `URL` for the file.
659
  /// - Throws: `URLError(.badURL)` if the resulting URL cannot be constructed.
660
  ///
661
  /// ## Example
662
  ///
663
  /// ```swift
664
  /// // Direct embed URL
665
  /// let url = try storage.from("avatars").getPublicURL(path: "user-123/photo.png")
666
  ///
667
  /// // 200×200 thumbnail in WebP
668
  /// let thumbURL = try storage.from("avatars").getPublicURL(
669
  ///   path: "user-123/photo.png",
670
  ///   options: TransformOptions(width: 200, height: 200, format: .webp)
671
  /// )
672
  /// ```
673
  public func getPublicURL(
674
    path: String,
675
    download: DownloadBehavior? = nil,
676
    options: TransformOptions? = nil,
677
    cacheNonce: String? = nil
678
  ) throws -> URL {
7✔
679
    var queryItems: [URLQueryItem] = []
7✔
680

7✔
681
    guard
7✔
682
      var components = URLComponents(
7✔
683
        url: client.url,
7✔
684
        resolvingAgainstBaseURL: true
7✔
685
      )
7✔
686
    else {
7✔
UNCOV
687
      throw URLError(.badURL)
×
688
    }
7✔
689

7✔
690
    if let download {
7✔
691
      let value: String
3✔
692
      switch download {
3✔
693
      case .withOriginalName: value = ""
3✔
694
      case .named(let name): value = name
3✔
695
      }
3✔
696
      queryItems.append(URLQueryItem(name: "download", value: value))
3✔
697
    }
7✔
698

7✔
699
    if let optionsQueryItems = options?.queryItems {
7✔
700
      queryItems.append(contentsOf: optionsQueryItems)
3✔
701
    }
3✔
702

7✔
703
    if let cacheNonce {
7✔
704
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
705
    }
1✔
706

7✔
707
    let renderPath =
7✔
708
      options.map { !$0.isEmpty } == true ? "render/image" : "object"
7✔
709
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
7✔
710
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
7✔
711

7✔
712
    guard let generatedUrl = components.url else {
7✔
UNCOV
713
      throw URLError(.badURL)
×
714
    }
7✔
715
    return generatedUrl
7✔
716
  }
7✔
717

718
  /// Creates a signed upload URL for uploading a file without further authentication.
719
  ///
720
  /// Signed upload URLs are valid for **2 hours** and allow anyone in possession of the URL to
721
  /// upload a file to the specified path. This is useful for client-side uploads where you do not
722
  /// want to expose Storage credentials.
723
  ///
724
  /// After calling this method, pass the returned ``SignedUploadURL/token`` to
725
  /// ``uploadToSignedURL(_:token:data:options:)`` or
726
  /// ``uploadToSignedURL(_:token:fileURL:options:)`` to perform the actual upload.
727
  ///
728
  /// - Parameters:
729
  ///   - path: The destination path within the bucket, e.g. `"user-123/upload.png"`.
730
  ///   - options: Optional overrides, such as enabling upsert behaviour.
731
  /// - Returns: A ``SignedUploadURL`` containing the signed URL, path, and extracted token.
732
  /// - Throws: ``StorageError`` if the request fails.
733
  ///
734
  /// ## Example
735
  ///
736
  /// ```swift
737
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/photo.png")
738
  ///
739
  /// // Later, upload the file using the signed token
740
  /// try await storage.from("uploads").uploadToSignedURL(
741
  ///   signed.path,
742
  ///   token: signed.token,
743
  ///   data: imageData,
744
  ///   options: FileOptions(contentType: "image/png")
745
  /// )
746
  /// ```
747
  public func createSignedUploadURL(
748
    path: String,
749
    options: CreateSignedUploadURLOptions? = nil
750
  ) async throws -> SignedUploadURL {
2✔
751
    struct Response: Decodable {
2✔
752
      let url: String
2✔
753
    }
2✔
754

2✔
755
    var headers = [String: String]()
2✔
756
    if let upsert = options?.upsert, upsert {
2✔
757
      headers[Header.xUpsert] = "true"
1✔
758
    }
1✔
759

2✔
760
    let response: Response = try await client.fetchDecoded(
2✔
761
      .post,
2✔
762
      "object/upload/sign/\(bucketId)/\(path)",
2✔
763
      headers: headers
2✔
764
    )
2✔
765

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

2✔
768
    guard
2✔
769
      let components = URLComponents(
2✔
770
        url: signedURL,
2✔
771
        resolvingAgainstBaseURL: false
2✔
772
      )
2✔
773
    else {
2✔
UNCOV
774
      throw URLError(.badURL)
×
775
    }
2✔
776

2✔
777
    guard
2✔
778
      let token = components.queryItems?.first(where: { $0.name == "token" })?
2✔
779
        .value
2✔
780
    else {
2✔
UNCOV
781
      throw StorageError.noTokenReturned
×
782
    }
2✔
783

2✔
784
    guard let url = components.url else {
2✔
UNCOV
785
      throw URLError(.badURL)
×
786
    }
2✔
787

2✔
788
    return SignedUploadURL(
2✔
789
      signedURL: url,
2✔
790
      path: path,
2✔
791
      token: token
2✔
792
    )
2✔
793
  }
2✔
794

795
  /// Uploads `Data` to a path using a token obtained from ``createSignedUploadURL(path:options:)``.
796
  ///
797
  /// The token must match the path and must not have expired (signed upload URLs are valid for
798
  /// 2 hours).
799
  ///
800
  /// - Parameters:
801
  ///   - path: The destination path within the bucket, matching the path used when the token
802
  ///     was created.
803
  ///   - token: The upload token from ``SignedUploadURL/token``.
804
  ///   - data: The raw file bytes to store.
805
  ///   - options: Upload options such as content type and cache duration.
806
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
807
  ///   result, or observed for progress.
808
  ///
809
  /// ## Example
810
  ///
811
  /// ```swift
812
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "file.pdf")
813
  /// let response = try await storage.from("uploads").uploadToSignedURL(
814
  ///   signed.path,
815
  ///   token: signed.token,
816
  ///   data: pdfData,
817
  ///   options: FileOptions(contentType: "application/pdf")
818
  /// ).value
819
  /// print(response.fullPath)
820
  /// ```
821
  @discardableResult
822
  public func uploadToSignedURL(
823
    _ path: String,
824
    token: String,
825
    data: Data,
826
    options: FileOptions = FileOptions()
827
  ) -> StorageTransferTask<SignedURLUploadResponse> {
1✔
828
    makeSignedURLUploadTask {
1✔
829
      try await self._uploadToSignedURL(
1✔
830
        path: path,
1✔
831
        token: token,
1✔
832
        file: .data(data),
1✔
833
        options: options
1✔
834
      )
1✔
835
    }
1✔
836
  }
1✔
837

838
  /// Uploads a file from a local `URL` to a path using a token obtained from
839
  /// ``createSignedUploadURL(path:options:)``.
840
  ///
841
  /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid
842
  /// loading the entire payload into memory.
843
  ///
844
  /// - Parameters:
845
  ///   - path: The destination path within the bucket, matching the path used when the token
846
  ///     was created.
847
  ///   - token: The upload token from ``SignedUploadURL/token``.
848
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
849
  ///   - options: Upload options such as content type and cache duration.
850
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
851
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
852
  ///   result, or observed for progress.
853
  ///
854
  /// ## Example
855
  ///
856
  /// ```swift
857
  /// let fileURL = URL(fileURLWithPath: "/tmp/video.mp4")
858
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/video.mp4")
859
  /// let response = try await storage.from("uploads").uploadToSignedURL(
860
  ///   signed.path,
861
  ///   token: signed.token,
862
  ///   fileURL: fileURL
863
  /// ).value
864
  /// ```
865
  @discardableResult
866
  public func uploadToSignedURL(
867
    _ path: String,
868
    token: String,
869
    fileURL: URL,
870
    options: FileOptions = FileOptions()
871
  ) -> StorageTransferTask<SignedURLUploadResponse> {
2✔
872
    makeSignedURLUploadTask {
2✔
873
      try await self._uploadToSignedURL(
2✔
874
        path: path,
2✔
875
        token: token,
2✔
876
        file: .fileURL(fileURL),
2✔
877
        options: options
2✔
878
      )
2✔
879
    }
2✔
880
  }
2✔
881

882
  private func makeSignedURLUploadTask(
883
    _ body: @Sendable @escaping () async throws -> SignedURLUploadResponse
884
  ) -> StorageTransferTask<SignedURLUploadResponse> {
3✔
885
    let (eventStream, eventsContinuation) =
3✔
886
      AsyncStream<TransferEvent<SignedURLUploadResponse>>.makeStream()
3✔
887
    let (resultStream, resultContinuation) =
3✔
888
      AsyncStream<Result<SignedURLUploadResponse, any Error>>.makeStream(
3✔
889
        bufferingPolicy: .bufferingNewest(1))
3✔
890

3✔
891
    let resultTask = Task<SignedURLUploadResponse, any Error> {
3✔
892
      for await r in resultStream { return try r.get() }
3✔
893
      throw StorageError.cancelled
×
894
    }
3✔
895

3✔
896
    let uploadTask = Task {
3✔
897
      do {
3✔
898
        let response = try await body()
3✔
899
        eventsContinuation.yield(.completed(response))
3✔
900
        eventsContinuation.finish()
3✔
901
        resultContinuation.yield(.success(response))
3✔
902
        resultContinuation.finish()
3✔
903
      } catch {
3✔
UNCOV
904
        let storageError = StorageError.from(error)
×
UNCOV
905
        eventsContinuation.yield(.failed(storageError))
×
UNCOV
906
        eventsContinuation.finish()
×
UNCOV
907
        resultContinuation.yield(.failure(storageError))
×
UNCOV
908
        resultContinuation.finish()
×
909
      }
3✔
910
    }
3✔
911

3✔
912
    return StorageTransferTask<SignedURLUploadResponse>(
3✔
913
      events: eventStream,
3✔
914
      resultTask: resultTask,
3✔
915
      pause: {},
3✔
916
      resume: {},
3✔
917
      cancel: { uploadTask.cancel() }
3✔
918
    )
3✔
919
  }
3✔
920

921
  private func _uploadToSignedURL(
922
    path: String,
923
    token: String,
924
    file: UploadSource,
925
    options: FileOptions
926
  ) async throws -> SignedURLUploadResponse {
3✔
927
    var headers = multipartHeaders(options: options)
3✔
928
    headers[Header.xUpsert] = "\(options.upsert)"
3✔
929

3✔
930
    let response: SignedUploadResponse = try await _performMultipartRequest(
3✔
931
      .put,
3✔
932
      url: storageURL(
3✔
933
        path: "object/upload/sign/\(bucketId)/\(path)",
3✔
934
        queryItems: [URLQueryItem(name: "token", value: token)]
3✔
935
      ),
3✔
936
      path: path,
3✔
937
      file: file,
3✔
938
      options: options,
3✔
939
      headers: headers
3✔
940
    )
3✔
941

3✔
942
    return SignedURLUploadResponse(path: path, fullPath: response.Key)
3✔
943
  }
3✔
944

945
  private func _performMultipartRequest<Response: Decodable>(
946
    _ method: HTTPMethod,
947
    url: URL,
948
    path: String,
949
    file: UploadSource,
950
    options: FileOptions,
951
    headers: [String: String]
952
  ) async throws -> Response {
3✔
953
    #if DEBUG
954
      let builder = MultipartBuilder(
3✔
955
        boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)"
3✔
956
      )
3✔
957
    #else
958
      let builder = MultipartBuilder()
959
    #endif
960

3✔
961
    let multipart = file.append(
3✔
962
      to: builder,
3✔
963
      withPath: path,
3✔
964
      options: options
3✔
965
    )
3✔
966

3✔
967
    var headers = headers
3✔
968
    headers[Header.contentType] = multipart.contentType
3✔
969

3✔
970
    let request = try await client.http.createRequest(
3✔
971
      method,
3✔
972
      url: url,
3✔
973
      headers: client.mergedHeaders(headers)
3✔
974
    )
3✔
975

3✔
976
    do {
3✔
977
      client.logRequest(method, url: url)
3✔
978
      let data: Data
3✔
979
      let response: URLResponse
3✔
980

3✔
981
      if file.usesTempFileUpload {
3✔
UNCOV
982
        let tempFile = try multipart.buildToTempFile()
×
UNCOV
983
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
UNCOV
984

×
UNCOV
985
        (data, response) = try await client.http.session.upload(
×
UNCOV
986
          for: request,
×
UNCOV
987
          fromFile: tempFile
×
UNCOV
988
        )
×
989
      } else {
3✔
990
        (data, response) = try await client.http.session.upload(
3✔
991
          for: request,
3✔
992
          from: try multipart.buildInMemory()
3✔
993
        )
3✔
994
      }
3✔
995

3✔
996
      let httpResponse = try client.http.validateResponse(response, data: data)
3✔
997
      client.logResponse(httpResponse, data: data)
3✔
998
      return try client.decoder.decode(Response.self, from: data)
3✔
999
    } catch {
3✔
UNCOV
1000
      client.logFailure(error)
×
UNCOV
1001
      throw client.translateStorageError(error)
×
UNCOV
1002
    }
×
1003
  }
3✔
1004

1005
  private func multipartHeaders(options: FileOptions) -> [String: String] {
3✔
1006
    var headers: [String: String] = [:]
3✔
1007
    headers.setIfMissing(
3✔
1008
      Header.cacheControl,
3✔
1009
      value: "max-age=\(options.cacheControl)"
3✔
1010
    )
3✔
1011
    return headers
3✔
1012
  }
3✔
1013

1014
  private func storageURL(path: String, queryItems: [URLQueryItem] = []) throws
1015
    -> URL
1016
  {
3✔
1017
    var components = URLComponents(
3✔
1018
      url: client.url.appendingPathComponent(path),
3✔
1019
      resolvingAgainstBaseURL: false
3✔
1020
    )
3✔
1021
    components?.queryItems = queryItems.isEmpty ? nil : queryItems
3✔
1022

3✔
1023
    guard let url = components?.url else {
3✔
UNCOV
1024
      throw URLError(.badURL)
×
1025
    }
3✔
1026

3✔
1027
    return url
3✔
1028
  }
3✔
1029

1030
  private func _getFinalPath(_ path: String) -> String {
1✔
1031
    "\(bucketId)/\(path)"
1✔
1032
  }
1✔
1033
}
1034

UNCOV
1035
func _removeEmptyFolders(_ path: String) -> String {
×
UNCOV
1036
  let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
×
UNCOV
1037
  let cleanedPath = trimmedPath.replacingOccurrences(
×
UNCOV
1038
    of: "/+",
×
UNCOV
1039
    with: "/",
×
UNCOV
1040
    options: .regularExpression
×
UNCOV
1041
  )
×
UNCOV
1042
  return cleanedPath
×
UNCOV
1043
}
×
1044

1045
extension [String: String] {
1046
  fileprivate mutating func setIfMissing(_ key: String, value: String) {
3✔
1047
    guard
3✔
1048
      keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame })
3✔
1049
        == nil
3✔
1050
    else {
3✔
UNCOV
1051
      return
×
1052
    }
3✔
1053

3✔
1054
    self[key] = value
3✔
1055
  }
3✔
1056
}
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