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

supabase / supabase-swift / 25488993349

07 May 2026 09:58AM UTC coverage: 79.768%. First build
25488993349

Pull #991

github

web-flow
Merge 0eefab23c into 6fa2a16c0
Pull Request #991: feat(storage): add StorageTransferTask, MultipartUploadEngine, and upload API

307 of 422 new or added lines in 6 files covered. (72.75%)

7282 of 9129 relevant lines covered (79.77%)

29.57 hits per line

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

92.24
/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 = {
37✔
66
    let encoder = JSONEncoder()
37✔
67
    #if DEBUG
68
      if isTesting {
37✔
69
        encoder.outputFormatting = .sortedKeys
37✔
70
      }
37✔
71
    #endif
72
    return encoder
37✔
73
  }()
37✔
74

75
  init(bucketId: String, client: StorageClient) {
37✔
76
    self.bucketId = bucketId
37✔
77
    self.client = client
37✔
78
  }
37✔
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
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
112
  ///
113
  /// If the path already exists and ``FileOptions/upsert`` is `false` (the default), an error
114
  /// is returned. Set `upsert: true` to overwrite silently instead.
115
  ///
116
  /// ## Example
117
  ///
118
  /// ```swift
119
  /// let response = try await storage.from("avatars").upload(
120
  ///   "user-123/photo.jpg",
121
  ///   data: imageData,
122
  ///   options: FileOptions(contentType: "image/jpeg")
123
  /// ).value
124
  /// ```
125
  @discardableResult
126
  public func upload(
127
    _ path: String,
128
    data: Data,
129
    options: FileOptions = FileOptions()
130
  ) -> StorageUploadTask {
5✔
131
    return MultipartUploadEngine.makeTask(
5✔
132
      bucketId: bucketId, path: path, source: .data(data), options: options, client: client)
5✔
133
  }
5✔
134

135
  /// Uploads a file from a local `URL` to an existing bucket.
136
  ///
137
  /// - Parameters:
138
  ///   - path: The destination path within the bucket, e.g. `"folder/image.png"`.
139
  ///     The bucket must already exist.
140
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
141
  ///   - options: Upload options such as content type, cache duration, and upsert behaviour.
142
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
143
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
144
  ///
145
  /// ## Example
146
  ///
147
  /// ```swift
148
  /// let response = try await storage.from("documents").upload(
149
  ///   "reports/2024/annual.pdf",
150
  ///   fileURL: fileURL
151
  /// ).value
152
  /// ```
153
  @discardableResult
154
  public func upload(
155
    _ path: String,
156
    fileURL: URL,
157
    options: FileOptions = FileOptions()
158
  ) -> StorageUploadTask {
2✔
159
    return MultipartUploadEngine.makeTask(
2✔
160
      bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options, client: client)
2✔
161
  }
2✔
162

163
  /// Replaces an existing file at the specified path with new `Data`.
164
  ///
165
  /// Always sets `upsert: true` to overwrite the existing object.
166
  ///
167
  /// - Parameters:
168
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
169
  ///   - data: The new raw file bytes.
170
  ///   - options: Upload options such as content type and cache duration.
171
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
172
  ///
173
  /// ## Example
174
  ///
175
  /// ```swift
176
  /// let response = try await storage.from("avatars").update(
177
  ///   "user-123/photo.png",
178
  ///   data: newImageData,
179
  ///   options: FileOptions(contentType: "image/png")
180
  /// ).value
181
  /// ```
182
  @discardableResult
183
  public func update(
184
    _ path: String,
185
    data: Data,
186
    options: FileOptions = FileOptions()
187
  ) -> StorageUploadTask {
3✔
188
    var upsertOptions = options
3✔
189
    upsertOptions.upsert = true
3✔
190
    return upload(path, data: data, options: upsertOptions)
3✔
191
  }
3✔
192

193
  /// Replaces an existing file at the specified path with the contents of a local `URL`.
194
  ///
195
  /// Always sets `upsert: true` to overwrite the existing object.
196
  ///
197
  /// - Parameters:
198
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
199
  ///   - fileURL: A local `file://` URL pointing to the replacement file.
200
  ///   - options: Upload options such as content type and cache duration.
201
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
202
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
203
  ///
204
  /// ## Example
205
  ///
206
  /// ```swift
207
  /// try await storage.from("documents").update(
208
  ///   "reports/2024/annual.pdf",
209
  ///   fileURL: newFileURL
210
  /// ).value
211
  /// ```
212
  @discardableResult
213
  public func update(
214
    _ path: String,
215
    fileURL: URL,
216
    options: FileOptions = FileOptions()
217
  ) -> StorageUploadTask {
1✔
218
    var upsertOptions = options
1✔
219
    upsertOptions.upsert = true
1✔
220
    return upload(path, fileURL: fileURL, options: upsertOptions)
1✔
221
  }
1✔
222
  /// Moves an existing file to a new path within the same or a different bucket.
223
  ///
224
  /// The source file is removed after the move completes. To keep the original, use ``copy(from:to:options:)`` instead.
225
  ///
226
  /// - Parameters:
227
  ///   - source: The current file path, e.g. `"folder/image.png"`.
228
  ///   - destination: The new file path, e.g. `"archive/image.png"`.
229
  ///   - options: Optional destination overrides, such as moving the file into a different bucket.
230
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
231
  ///
232
  /// ## Example
233
  ///
234
  /// ```swift
235
  /// // Rename within the same bucket
236
  /// try await storage.from("avatars").move(from: "old-name.png", to: "new-name.png")
237
  ///
238
  /// // Move to a different bucket
239
  /// try await storage.from("avatars").move(
240
  ///   from: "user-123/photo.png",
241
  ///   to: "user-123/photo.png",
242
  ///   options: DestinationOptions(destinationBucket: "archive")
243
  /// )
244
  /// ```
245
  public func move(
246
    from source: String,
247
    to destination: String,
248
    options: DestinationOptions? = nil
249
  ) async throws {
3✔
250
    let body: [String: String?] = [
3✔
251
      "bucketId": bucketId,
3✔
252
      "sourceKey": source,
3✔
253
      "destinationKey": destination,
3✔
254
      "destinationBucket": options?.destinationBucket,
3✔
255
    ]
3✔
256

3✔
257
    try await client.fetchData(
3✔
258
      .post,
3✔
259
      "object/move",
3✔
260
      body: .data(client.encoder.encode(body))
3✔
261
    )
3✔
262
  }
1✔
263

264
  /// Copies an existing file to a new path, leaving the original in place.
265
  ///
266
  /// To move a file without keeping the original, use ``move(from:to:options:)`` instead.
267
  ///
268
  /// - Parameters:
269
  ///   - source: The current file path, e.g. `"folder/image.png"`.
270
  ///   - destination: The path for the copy, e.g. `"folder/image-copy.png"`.
271
  ///   - options: Optional destination overrides, such as copying the file into a different bucket.
272
  /// - Returns: The full storage key of the newly created copy.
273
  /// - Throws: ``StorageError`` if the source path does not exist or the request fails.
274
  ///
275
  /// ## Example
276
  ///
277
  /// ```swift
278
  /// // Duplicate a file in the same bucket
279
  /// let key = try await storage.from("avatars").copy(
280
  ///   from: "user-123/photo.png",
281
  ///   to: "user-123/photo-backup.png"
282
  /// )
283
  /// print(key) // "avatars/user-123/photo-backup.png"
284
  /// ```
285
  @discardableResult
286
  public func copy(
287
    from source: String,
288
    to destination: String,
289
    options: DestinationOptions? = nil
290
  ) async throws -> String {
1✔
291
    struct UploadResponse: Decodable {
1✔
292
      let Key: String
1✔
293
    }
1✔
294

1✔
295
    let body: [String: String?] = [
1✔
296
      "bucketId": bucketId,
1✔
297
      "sourceKey": source,
1✔
298
      "destinationKey": destination,
1✔
299
      "destinationBucket": options?.destinationBucket,
1✔
300
    ]
1✔
301

1✔
302
    let response: UploadResponse = try await client.fetchDecoded(
1✔
303
      .post,
1✔
304
      "object/copy",
1✔
305
      body: .data(client.encoder.encode(body))
1✔
306
    )
1✔
307

1✔
308
    return response.Key
1✔
309
  }
1✔
310

311
  /// Creates a signed URL that grants time-limited access to a private file.
312
  ///
313
  /// Signed URLs can be shared with unauthenticated users. They expire after `expiresIn` and
314
  /// cannot be revoked once issued, so avoid long expiration windows for sensitive files.
315
  ///
316
  /// - Parameters:
317
  ///   - path: The file path within the bucket, e.g. `"folder/image.png"`.
318
  ///   - expiresIn: How long until the signed URL expires, e.g. `.seconds(3600)` for one hour.
319
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
320
  ///   - transform: Optional on-the-fly image transformation applied before the file is served.
321
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
322
  /// - Returns: A signed `URL` ready to be shared or embedded.
323
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
324
  ///
325
  /// ## Example
326
  ///
327
  /// ```swift
328
  /// // Short-lived link for streaming a private video
329
  /// let url = try await storage.from("media")
330
  ///   .createSignedURL(path: "user-123/video.mp4", expiresIn: .seconds(3600))
331
  ///
332
  /// // Signed download link with a custom filename
333
  /// let downloadURL = try await storage.from("reports")
334
  ///   .createSignedURL(
335
  ///     path: "q4-2024.pdf",
336
  ///     expiresIn: .seconds(300),
337
  ///     download: .named("Q4-2024-Report.pdf")
338
  ///   )
339
  /// ```
340
  public func createSignedURL(
341
    path: String,
342
    expiresIn: Duration,
343
    download: DownloadBehavior? = nil,
344
    transform: TransformOptions? = nil,
345
    cacheNonce: String? = nil
346
  ) async throws -> URL {
3✔
347
    struct Body: Encodable {
3✔
348
      let expiresIn: Int
3✔
349
      let transform: TransformOptions?
3✔
350
    }
3✔
351

3✔
352
    let response: SignedURLAPIResponse = try await client.fetchDecoded(
3✔
353
      .post,
3✔
354
      "object/sign/\(bucketId)/\(path)",
3✔
355
      body: .data(
3✔
356
        originalEncoder.encode(
3✔
357
          Body(
3✔
358
            expiresIn: Int(expiresIn.components.seconds),
3✔
359
            transform: transform
3✔
360
          )
3✔
361
        )
3✔
362
      )
3✔
363
    )
3✔
364

3✔
365
    return try makeSignedURL(
3✔
366
      response.signedURL,
3✔
367
      download: download,
3✔
368
      cacheNonce: cacheNonce
3✔
369
    )
3✔
370
  }
3✔
371

372
  /// Creates signed URLs for multiple files in a single request.
373
  ///
374
  /// - Parameters:
375
  ///   - paths: The file paths within the bucket.
376
  ///   - expiresIn: How long until the signed URLs expire.
377
  ///   - download: When non-`nil`, the browser treats each URL as a file download.
378
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
379
  /// - Returns: An array of ``SignedURLResult`` values, one per input path.
380
  /// - Throws: ``StorageError`` if the batch request itself fails.
381
  ///
382
  /// ## Example
383
  ///
384
  /// ```swift
385
  /// let results = try await storage.from("media").createSignedURLs(
386
  ///   paths: ["photo1.jpg", "photo2.jpg", "missing.jpg"],
387
  ///   expiresIn: .seconds(3600)
388
  /// )
389
  ///
390
  /// for result in results {
391
  ///   switch result {
392
  ///   case .success(let path, let url):
393
  ///     print("\(path) → \(url)")
394
  ///   case .failure(let path, let error):
395
  ///     print("\(path) failed: \(error)")
396
  ///   }
397
  /// }
398
  /// ```
399
  public func createSignedURLs(
400
    paths: [String],
401
    expiresIn: Duration,
402
    download: DownloadBehavior? = nil,
403
    cacheNonce: String? = nil
404
  ) async throws -> [SignedURLResult] {
5✔
405
    struct Params: Encodable {
5✔
406
      let expiresIn: Int
5✔
407
      let paths: [String]
5✔
408
    }
5✔
409

5✔
410
    let response: [SignedURLsAPIResponse] = try await client.fetchDecoded(
5✔
411
      .post,
5✔
412
      "object/sign/\(bucketId)",
5✔
413
      body: .data(
5✔
414
        originalEncoder.encode(
5✔
415
          Params(expiresIn: Int(expiresIn.components.seconds), paths: paths)
5✔
416
        )
5✔
417
      )
5✔
418
    )
5✔
419

5✔
420
    return try response.map { item in
9✔
421
      if let signedURLString = item.signedURL {
9✔
422
        let url = try makeSignedURL(
8✔
423
          signedURLString,
8✔
424
          download: download,
8✔
425
          cacheNonce: cacheNonce
8✔
426
        )
8✔
427
        return .success(path: item.path, signedURL: url)
8✔
428
      } else {
8✔
429
        return .failure(path: item.path, error: item.error ?? "Unknown error")
1✔
430
      }
1✔
431
    }
9✔
432
  }
5✔
433

434
  private func makeSignedURL(
435
    _ signedURL: String,
436
    download: DownloadBehavior?,
437
    cacheNonce: String? = nil
438
  ) throws -> URL {
13✔
439
    guard let signedURLComponents = URLComponents(string: signedURL),
13✔
440
      var baseComponents = URLComponents(
13✔
441
        url: client.url,
13✔
442
        resolvingAgainstBaseURL: false
13✔
443
      )
13✔
444
    else {
13✔
445
      throw URLError(.badURL)
×
446
    }
13✔
447

13✔
448
    baseComponents.path +=
13✔
449
      signedURLComponents.path.hasPrefix("/")
13✔
450
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
451
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
452

13✔
453
    if let download {
13✔
454
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
455
      let value: String
3✔
456
      switch download {
3✔
457
      case .withOriginalName: value = ""
3✔
458
      case .named(let name): value = name
3✔
459
      }
3✔
460
      baseComponents.queryItems!.append(
3✔
461
        URLQueryItem(name: "download", value: value)
3✔
462
      )
3✔
463
    }
13✔
464

13✔
465
    if let cacheNonce {
13✔
466
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
467
      baseComponents.queryItems!.append(
2✔
468
        URLQueryItem(name: "cacheNonce", value: cacheNonce)
2✔
469
      )
2✔
470
    }
2✔
471

13✔
472
    guard let url = baseComponents.url else {
13✔
473
      throw URLError(.badURL)
×
474
    }
13✔
475
    return url
13✔
476
  }
13✔
477

478
  /// Deletes one or more files from the bucket.
479
  ///
480
  /// All listed paths are deleted in a single request. Non-existent paths are silently ignored.
481
  ///
482
  /// - Parameter paths: The paths of the files to delete, e.g. `["folder/image.png", "other.pdf"]`.
483
  /// - Returns: An array of ``FileObject`` values describing the deleted files.
484
  /// - Throws: ``StorageError`` if the request fails.
485
  ///
486
  /// ## Example
487
  ///
488
  /// ```swift
489
  /// let deleted = try await storage.from("avatars").remove(paths: [
490
  ///   "user-123/photo.png",
491
  ///   "user-456/photo.png"
492
  /// ])
493
  /// print("Deleted \(deleted.count) file(s)")
494
  /// ```
495
  @discardableResult
496
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
497
    try await client.fetchDecoded(
1✔
498
      .delete,
1✔
499
      "object/\(bucketId)",
1✔
500
      body: .data(client.encoder.encode(["prefixes": paths]))
1✔
501
    )
1✔
502
  }
1✔
503

504
  /// Lists files and folders within the bucket, optionally scoped to a path prefix.
505
  ///
506
  /// Results include both files and virtual folder entries. Paginate large buckets using
507
  /// ``SearchOptions/limit`` and ``SearchOptions/offset``.
508
  ///
509
  /// - Parameters:
510
  ///   - path: An optional folder path to list. When `nil`, lists the bucket root.
511
  ///   - options: Filtering, sorting, and pagination options. Defaults to 100 items sorted
512
  ///     by name ascending when `nil`.
513
  /// - Returns: An array of ``FileObject`` values representing the matching files and folders.
514
  /// - Throws: ``StorageError`` if the request fails.
515
  ///
516
  /// ## Example
517
  ///
518
  /// ```swift
519
  /// // List all files in the "user-123" folder
520
  /// let files = try await storage.from("avatars").list(path: "user-123")
521
  ///
522
  /// // Paginate with custom sort
523
  /// let page2 = try await storage.from("photos").list(
524
  ///   path: "gallery",
525
  ///   options: SearchOptions(limit: 20, offset: 20, sortBy: SortBy(column: "created_at", order: .descending))
526
  /// )
527
  /// ```
528
  public func list(
529
    path: String? = nil,
530
    options: SearchOptions? = nil
531
  ) async throws -> [FileObject] {
1✔
532
    var options = options ?? defaultSearchOptions
1✔
533
    options.prefix = path ?? ""
1✔
534

1✔
535
    return try await client.fetchDecoded(
1✔
536
      .post,
1✔
537
      "object/list/\(bucketId)",
1✔
538
      body: .data(originalEncoder.encode(options))
1✔
539
    )
1✔
540
  }
1✔
541
  /// Retrieves extended metadata for a file without downloading its contents.
542
  ///
543
  /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other
544
  /// server-side metadata. To check only whether a file exists, prefer ``exists(path:)``.
545
  ///
546
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
547
  /// - Returns: A ``FileInfo`` containing the file's metadata.
548
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
549
  ///
550
  /// ## Example
551
  ///
552
  /// ```swift
553
  /// let info = try await storage.from("avatars").info(path: "user-123/photo.png")
554
  /// print("Size: \(info.size ?? 0) bytes, type: \(info.contentType ?? "unknown")")
555
  /// ```
556
  public func info(path: String) async throws -> FileInfo {
1✔
557
    let _path = _getFinalPath(path)
1✔
558

1✔
559
    return try await client.fetchDecoded(.get, "object/info/\(_path)")
1✔
560
  }
1✔
561

562
  /// Checks whether a file exists in the bucket.
563
  ///
564
  /// Issues a `HEAD` request and returns `true` if the server responds with a success status,
565
  /// `false` if the server returns 400 or 404. Any other error is re-thrown.
566
  ///
567
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
568
  /// - Returns: `true` if the file exists, `false` if it does not.
569
  /// - Throws: ``StorageError`` for server errors other than 400/404.
570
  ///
571
  /// ## Example
572
  ///
573
  /// ```swift
574
  /// if try await storage.from("avatars").exists(path: "user-123/photo.png") {
575
  ///   print("File found")
576
  /// } else {
577
  ///   print("File not found")
578
  /// }
579
  /// ```
580
  public func exists(path: String) async throws -> Bool {
3✔
581
    do {
3✔
582
      try await client.fetchData(.head, "object/\(bucketId)/\(path)")
3✔
583
      return true
1✔
584
    } catch let error as StorageError
3✔
585
      where error.isNotFound || error.statusCode == 400
3✔
586
    {
3✔
587
      // The Storage server returns 400 (instead of 404) for HEAD requests on non-existent objects.
2✔
588
      return false
2✔
589
    }
2✔
590
  }
3✔
591

592
  /// Returns the public URL for a file in a public bucket.
593
  ///
594
  /// The bucket must be configured as public; for private buckets use
595
  /// ``createSignedURL(path:expiresIn:download:transform:cacheNonce:)`` instead.
596
  ///
597
  /// - Parameters:
598
  ///   - path: The path of the file within the bucket, e.g. `"user-123/avatar.png"`.
599
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
600
  ///   - options: Optional on-the-fly image transformation (resize, reformat, quality).
601
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
602
  /// - Returns: The public `URL` for the file.
603
  /// - Throws: `URLError(.badURL)` if the resulting URL cannot be constructed.
604
  ///
605
  /// ## Example
606
  ///
607
  /// ```swift
608
  /// // Direct embed URL
609
  /// let url = try storage.from("avatars").getPublicURL(path: "user-123/photo.png")
610
  ///
611
  /// // 200×200 thumbnail in WebP
612
  /// let thumbURL = try storage.from("avatars").getPublicURL(
613
  ///   path: "user-123/photo.png",
614
  ///   options: TransformOptions(width: 200, height: 200, format: .webp)
615
  /// )
616
  /// ```
617
  public func getPublicURL(
618
    path: String,
619
    download: DownloadBehavior? = nil,
620
    options: TransformOptions? = nil,
621
    cacheNonce: String? = nil
622
  ) throws -> URL {
7✔
623
    var queryItems: [URLQueryItem] = []
7✔
624

7✔
625
    guard
7✔
626
      var components = URLComponents(
7✔
627
        url: client.url,
7✔
628
        resolvingAgainstBaseURL: true
7✔
629
      )
7✔
630
    else {
7✔
631
      throw URLError(.badURL)
×
632
    }
7✔
633

7✔
634
    if let download {
7✔
635
      let value: String
3✔
636
      switch download {
3✔
637
      case .withOriginalName: value = ""
3✔
638
      case .named(let name): value = name
3✔
639
      }
3✔
640
      queryItems.append(URLQueryItem(name: "download", value: value))
3✔
641
    }
7✔
642

7✔
643
    if let optionsQueryItems = options?.queryItems {
7✔
644
      queryItems.append(contentsOf: optionsQueryItems)
3✔
645
    }
3✔
646

7✔
647
    if let cacheNonce {
7✔
648
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
649
    }
1✔
650

7✔
651
    let renderPath =
7✔
652
      options.map { !$0.isEmpty } == true ? "render/image" : "object"
7✔
653
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
7✔
654
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
7✔
655

7✔
656
    guard let generatedUrl = components.url else {
7✔
657
      throw URLError(.badURL)
×
658
    }
7✔
659
    return generatedUrl
7✔
660
  }
7✔
661

662
  /// Creates a signed upload URL for uploading a file without further authentication.
663
  ///
664
  /// Signed upload URLs are valid for **2 hours** and allow anyone in possession of the URL to
665
  /// upload a file to the specified path. This is useful for client-side uploads where you do not
666
  /// want to expose Storage credentials.
667
  ///
668
  /// After calling this method, pass the returned ``SignedUploadURL/token`` to
669
  /// ``uploadToSignedURL(_:token:data:options:)`` or
670
  /// ``uploadToSignedURL(_:token:fileURL:options:)`` to perform the actual upload.
671
  ///
672
  /// - Parameters:
673
  ///   - path: The destination path within the bucket, e.g. `"user-123/upload.png"`.
674
  ///   - options: Optional overrides, such as enabling upsert behaviour.
675
  /// - Returns: A ``SignedUploadURL`` containing the signed URL, path, and extracted token.
676
  /// - Throws: ``StorageError`` if the request fails.
677
  ///
678
  /// ## Example
679
  ///
680
  /// ```swift
681
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/photo.png")
682
  ///
683
  /// // Later, upload the file using the signed token
684
  /// try await storage.from("uploads").uploadToSignedURL(
685
  ///   signed.path,
686
  ///   token: signed.token,
687
  ///   data: imageData,
688
  ///   options: FileOptions(contentType: "image/png")
689
  /// )
690
  /// ```
691
  public func createSignedUploadURL(
692
    path: String,
693
    options: CreateSignedUploadURLOptions? = nil
694
  ) async throws -> SignedUploadURL {
2✔
695
    struct Response: Decodable {
2✔
696
      let url: String
2✔
697
    }
2✔
698

2✔
699
    var headers = [String: String]()
2✔
700
    if let upsert = options?.upsert, upsert {
2✔
701
      headers[Header.xUpsert] = "true"
1✔
702
    }
1✔
703

2✔
704
    let response: Response = try await client.fetchDecoded(
2✔
705
      .post,
2✔
706
      "object/upload/sign/\(bucketId)/\(path)",
2✔
707
      headers: headers
2✔
708
    )
2✔
709

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

2✔
712
    guard
2✔
713
      let components = URLComponents(
2✔
714
        url: signedURL,
2✔
715
        resolvingAgainstBaseURL: false
2✔
716
      )
2✔
717
    else {
2✔
718
      throw URLError(.badURL)
×
719
    }
2✔
720

2✔
721
    guard
2✔
722
      let token = components.queryItems?.first(where: { $0.name == "token" })?
2✔
723
        .value
2✔
724
    else {
2✔
725
      throw StorageError.noTokenReturned
×
726
    }
2✔
727

2✔
728
    guard let url = components.url else {
2✔
729
      throw URLError(.badURL)
×
730
    }
2✔
731

2✔
732
    return SignedUploadURL(
2✔
733
      signedURL: url,
2✔
734
      path: path,
2✔
735
      token: token
2✔
736
    )
2✔
737
  }
2✔
738

739
  /// Uploads `Data` to a path using a token obtained from ``createSignedUploadURL(path:options:)``.
740
  ///
741
  /// The token must match the path and must not have expired (signed upload URLs are valid for
742
  /// 2 hours).
743
  ///
744
  /// - Parameters:
745
  ///   - path: The destination path within the bucket, matching the path used when the token
746
  ///     was created.
747
  ///   - token: The upload token from ``SignedUploadURL/token``.
748
  ///   - data: The raw file bytes to store.
749
  ///   - options: Upload options such as content type and cache duration.
750
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
751
  ///   result, or observed for progress.
752
  ///
753
  /// ## Example
754
  ///
755
  /// ```swift
756
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "file.pdf")
757
  /// let response = try await storage.from("uploads").uploadToSignedURL(
758
  ///   signed.path,
759
  ///   token: signed.token,
760
  ///   data: pdfData,
761
  ///   options: FileOptions(contentType: "application/pdf")
762
  /// ).value
763
  /// print(response.fullPath)
764
  /// ```
765
  @discardableResult
766
  public func uploadToSignedURL(
767
    _ path: String,
768
    token: String,
769
    data: Data,
770
    options: FileOptions = FileOptions()
771
  ) -> StorageTransferTask<SignedURLUploadResponse> {
1✔
772
    makeSignedURLUploadTask {
1✔
773
      try await self._uploadToSignedURL(
1✔
774
        path: path,
1✔
775
        token: token,
1✔
776
        file: .data(data),
1✔
777
        options: options
1✔
778
      )
1✔
779
    }
1✔
780
  }
1✔
781

782
  /// Uploads a file from a local `URL` to a path using a token obtained from
783
  /// ``createSignedUploadURL(path:options:)``.
784
  ///
785
  /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid
786
  /// loading the entire payload into memory.
787
  ///
788
  /// - Parameters:
789
  ///   - path: The destination path within the bucket, matching the path used when the token
790
  ///     was created.
791
  ///   - token: The upload token from ``SignedUploadURL/token``.
792
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
793
  ///   - options: Upload options such as content type and cache duration.
794
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
795
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
796
  ///   result, or observed for progress.
797
  ///
798
  /// ## Example
799
  ///
800
  /// ```swift
801
  /// let fileURL = URL(fileURLWithPath: "/tmp/video.mp4")
802
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/video.mp4")
803
  /// let response = try await storage.from("uploads").uploadToSignedURL(
804
  ///   signed.path,
805
  ///   token: signed.token,
806
  ///   fileURL: fileURL
807
  /// ).value
808
  /// ```
809
  @discardableResult
810
  public func uploadToSignedURL(
811
    _ path: String,
812
    token: String,
813
    fileURL: URL,
814
    options: FileOptions = FileOptions()
815
  ) -> StorageTransferTask<SignedURLUploadResponse> {
2✔
816
    makeSignedURLUploadTask {
2✔
817
      try await self._uploadToSignedURL(
2✔
818
        path: path,
2✔
819
        token: token,
2✔
820
        file: .fileURL(fileURL),
2✔
821
        options: options
2✔
822
      )
2✔
823
    }
2✔
824
  }
2✔
825

826
  private func makeSignedURLUploadTask(
827
    _ body: @Sendable @escaping () async throws -> SignedURLUploadResponse
828
  ) -> StorageTransferTask<SignedURLUploadResponse> {
3✔
829
    let (eventStream, eventsContinuation) =
3✔
830
      AsyncStream<TransferEvent<SignedURLUploadResponse>>.makeStream()
3✔
831
    let (resultStream, resultContinuation) =
3✔
832
      AsyncStream<Result<SignedURLUploadResponse, any Error>>.makeStream(
3✔
833
        bufferingPolicy: .bufferingNewest(1))
3✔
834

3✔
835
    let resultTask = Task<SignedURLUploadResponse, any Error> {
3✔
836
      for await r in resultStream { return try r.get() }
3✔
NEW
837
      throw StorageError.cancelled
×
838
    }
3✔
839

3✔
840
    let uploadTask = Task {
3✔
841
      do {
3✔
842
        let response = try await body()
3✔
843
        eventsContinuation.yield(.completed(response))
3✔
844
        eventsContinuation.finish()
3✔
845
        resultContinuation.yield(.success(response))
3✔
846
        resultContinuation.finish()
3✔
847
      } catch {
3✔
NEW
848
        let storageError = StorageError.from(error)
×
NEW
849
        eventsContinuation.yield(.failed(storageError))
×
NEW
850
        eventsContinuation.finish()
×
NEW
851
        resultContinuation.yield(.failure(storageError))
×
NEW
852
        resultContinuation.finish()
×
853
      }
3✔
854
    }
3✔
855

3✔
856
    return StorageTransferTask<SignedURLUploadResponse>(
3✔
857
      events: eventStream,
3✔
858
      resultTask: resultTask,
3✔
859
      pause: {},
3✔
860
      resume: {},
3✔
861
      cancel: { uploadTask.cancel() }
3✔
862
    )
3✔
863
  }
3✔
864

865
  private func _uploadToSignedURL(
866
    path: String,
867
    token: String,
868
    file: UploadSource,
869
    options: FileOptions
870
  ) async throws -> SignedURLUploadResponse {
3✔
871
    var headers = multipartHeaders(options: options)
3✔
872
    headers[Header.xUpsert] = "\(options.upsert)"
3✔
873

3✔
874
    let response: SignedUploadResponse = try await _performMultipartRequest(
3✔
875
      .put,
3✔
876
      url: storageURL(
3✔
877
        path: "object/upload/sign/\(bucketId)/\(path)",
3✔
878
        queryItems: [URLQueryItem(name: "token", value: token)]
3✔
879
      ),
3✔
880
      path: path,
3✔
881
      file: file,
3✔
882
      options: options,
3✔
883
      headers: headers
3✔
884
    )
3✔
885

3✔
886
    return SignedURLUploadResponse(path: path, fullPath: response.Key)
3✔
887
  }
3✔
888

889
  private func _performMultipartRequest<Response: Decodable>(
890
    _ method: HTTPMethod,
891
    url: URL,
892
    path: String,
893
    file: UploadSource,
894
    options: FileOptions,
895
    headers: [String: String]
896
  ) async throws -> Response {
3✔
897
    #if DEBUG
898
      let builder = MultipartBuilder(
3✔
899
        boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)"
3✔
900
      )
3✔
901
    #else
902
      let builder = MultipartBuilder()
903
    #endif
904

3✔
905
    let multipart = file.append(
3✔
906
      to: builder,
3✔
907
      withPath: path,
3✔
908
      options: options
3✔
909
    )
3✔
910

3✔
911
    var headers = headers
3✔
912
    headers[Header.contentType] = multipart.contentType
3✔
913

3✔
914
    let request = try await client.http.createRequest(
3✔
915
      method,
3✔
916
      url: url,
3✔
917
      headers: client.mergedHeaders(headers)
3✔
918
    )
3✔
919

3✔
920
    do {
3✔
921
      client.logRequest(method, url: url)
3✔
922
      let data: Data
3✔
923
      let response: URLResponse
3✔
924

3✔
925
      if file.usesTempFileUpload {
3✔
926
        let tempFile = try multipart.buildToTempFile()
×
927
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
928

×
929
        (data, response) = try await client.http.session.upload(
×
930
          for: request,
×
NEW
931
          fromFile: tempFile
×
932
        )
×
933
      } else {
3✔
934
        (data, response) = try await client.http.session.upload(
3✔
935
          for: request,
3✔
936
          from: try multipart.buildInMemory()
3✔
937
        )
3✔
938
      }
3✔
939

3✔
940
      let httpResponse = try client.http.validateResponse(response, data: data)
3✔
941
      client.logResponse(httpResponse, data: data)
3✔
942
      return try client.decoder.decode(Response.self, from: data)
3✔
943
    } catch {
3✔
944
      client.logFailure(error)
×
945
      throw client.translateStorageError(error)
×
946
    }
×
947
  }
3✔
948

949
  private func multipartHeaders(options: FileOptions) -> [String: String] {
3✔
950
    var headers: [String: String] = [:]
3✔
951
    headers.setIfMissing(
3✔
952
      Header.cacheControl,
3✔
953
      value: "max-age=\(options.cacheControl)"
3✔
954
    )
3✔
955
    return headers
3✔
956
  }
3✔
957

958
  private func storageURL(path: String, queryItems: [URLQueryItem] = []) throws
959
    -> URL
960
  {
3✔
961
    var components = URLComponents(
3✔
962
      url: client.url.appendingPathComponent(path),
3✔
963
      resolvingAgainstBaseURL: false
3✔
964
    )
3✔
965
    components?.queryItems = queryItems.isEmpty ? nil : queryItems
3✔
966

3✔
967
    guard let url = components?.url else {
3✔
968
      throw URLError(.badURL)
×
969
    }
3✔
970

3✔
971
    return url
3✔
972
  }
3✔
973

974
  private func _getFinalPath(_ path: String) -> String {
1✔
975
    "\(bucketId)/\(path)"
1✔
976
  }
1✔
977
}
978

979
func _removeEmptyFolders(_ path: String) -> String {
×
980
  let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
×
981
  let cleanedPath = trimmedPath.replacingOccurrences(
×
982
    of: "/+",
×
983
    with: "/",
×
984
    options: .regularExpression
×
985
  )
×
986
  return cleanedPath
×
987
}
×
988

989
extension [String: String] {
990
  fileprivate mutating func setIfMissing(_ key: String, value: String) {
3✔
991
    guard
3✔
992
      keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame })
3✔
993
        == nil
3✔
994
    else {
3✔
995
      return
×
996
    }
3✔
997

3✔
998
    self[key] = value
3✔
999
  }
3✔
1000
}
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