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

supabase / supabase-swift / 29440434768

15 Jul 2026 06:24PM UTC coverage: 83.467% (-0.09%) from 83.556%
29440434768

Pull #1123

github

web-flow
Merge 0357fdc58 into 09e784e7a
Pull Request #1123: feat(realtime): support certificate pinning on the WebSocket connection

94 of 120 new or added lines in 4 files covered. (78.33%)

36 existing lines in 4 files now uncovered.

7992 of 9575 relevant lines covered (83.47%)

38.12 hits per line

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

97.87
/Sources/Storage/StorageFileApi.swift
1
public import Foundation
2
import HTTPTypes
3

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

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

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

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

27
  func encode(to formData: MultipartFormData, withPath path: String, options: FileOptions) {
9✔
28
    formData.append(
9✔
29
      options.cacheControl.data(using: .utf8)!,
9✔
30
      withName: "cacheControl"
9✔
31
    )
9✔
32

9✔
33
    if let metadata = options.metadata {
9✔
34
      formData.append(encodeMetadata(metadata), withName: "metadata")
4✔
35
    }
4✔
36

9✔
37
    switch self {
9✔
38
    case .data(let data):
9✔
39
      formData.append(
5✔
40
        data,
5✔
41
        withName: "",
5✔
42
        fileName: path.fileName,
5✔
43
        mimeType: options.contentType ?? mimeType(forPathExtension: path.pathExtension)
5✔
44
      )
5✔
45

9✔
46
    case .url(let url):
9✔
47
      formData.append(
4✔
48
        url,
4✔
49
        withName: "",
4✔
50
        fileName: url.lastPathComponent,
4✔
51
        mimeType: options.contentType ?? mimeType(forPathExtension: url.pathExtension)
4✔
52
      )
4✔
53
    }
9✔
54
  }
9✔
55
}
56

57
#if DEBUG
58
  import ConcurrencyExtras
59
  let testingBoundary = LockIsolated<String?>(nil)
1✔
60
#endif
61

62
/// Supabase Storage File API for file operations within a specific bucket.
63
///
64
/// Obtain a ``StorageFileApi`` by calling ``SupabaseStorageClient/from(_:)`` with the bucket
65
/// identifier you want to operate on:
66
///
67
/// ```swift
68
/// let fileApi = storage.from("avatars")
69
///
70
/// // Upload a PNG
71
/// try await fileApi.upload("user123.png", data: imageData)
72
///
73
/// // Generate a signed URL valid for 60 seconds
74
/// let url = try await fileApi.createSignedURL(path: "user123.png", expiresIn: 60)
75
/// ```
76
///
77
/// > Note: This class is `@unchecked Sendable`. The ``bucketId`` property is immutable (`let`), and
78
/// > all mutable header state is protected by the lock inherited from ``StorageApi``.
79
///
80
/// ## Topics
81
///
82
/// ### Uploading files
83
///
84
/// - ``upload(_:data:options:)``
85
/// - ``upload(_:fileURL:options:)``
86
/// - ``update(_:data:options:)``
87
/// - ``update(_:fileURL:options:)``
88
///
89
/// ### Uploading via signed URLs
90
///
91
/// - ``createSignedUploadURL(path:options:)``
92
/// - ``uploadToSignedURL(_:token:data:options:)``
93
/// - ``uploadToSignedURL(_:token:fileURL:options:)``
94
///
95
/// ### Downloading files
96
///
97
/// - ``download(path:options:query:cacheNonce:)``
98
/// - ``getPublicURL(path:download:options:cacheNonce:)``
99
///
100
/// ### Managing files
101
///
102
/// - ``move(from:to:options:)``
103
/// - ``copy(from:to:options:)``
104
/// - ``remove(paths:)``
105
/// - ``list(path:options:)``
106
/// - ``info(path:)``
107
/// - ``exists(path:)``
108
///
109
/// ### Creating signed URLs
110
///
111
/// - ``createSignedURL(path:expiresIn:download:transform:cacheNonce:)``
112
/// - ``createSignedURLs(paths:expiresIn:download:cacheNonce:)``
113
public class StorageFileApi: StorageApi, @unchecked Sendable {
114
  /// The identifier of the bucket this instance operates on.
115
  let bucketId: String
116

117
  init(bucketId: String, configuration: StorageClientConfiguration) {
59✔
118
    self.bucketId = bucketId
59✔
119
    super.init(configuration: configuration)
59✔
120
  }
59✔
121

122
  private struct MoveResponse: Decodable {
123
    let message: String
124
  }
125

126
  private struct SignedURLAPIResponse: Decodable {
127
    let signedURL: String
128
  }
129

130
  private struct SignedURLsAPIResponse: Decodable {
131
    let signedURL: String?
132
    let path: String
133
    let error: String?
134
  }
135

136
  private func _uploadOrUpdate(
137
    method: HTTPTypes.HTTPRequest.Method,
138
    path: String,
139
    file: FileUpload,
140
    options: FileOptions?
141
  ) async throws -> FileUploadResponse {
6✔
142
    let options = options ?? defaultFileOptions
6✔
143
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
6✔
144

6✔
145
    if method == .post {
6✔
146
      headers[.xUpsert] = "\(options.upsert)"
4✔
147
    }
4✔
148

6✔
149
    headers[.duplex] = options.duplex
6✔
150

6✔
151
    #if DEBUG
152
      let formData = MultipartFormData(boundary: testingBoundary.value)
6✔
153
    #else
154
      let formData = MultipartFormData()
155
    #endif
156
    file.encode(to: formData, withPath: path, options: options)
6✔
157

6✔
158
    struct UploadResponse: Decodable {
6✔
159
      let Key: String
6✔
160
      let Id: String
6✔
161
    }
6✔
162

6✔
163
    let cleanPath = _removeEmptyFolders(path)
6✔
164
    let _path = _getFinalPath(cleanPath)
6✔
165

6✔
166
    let response = try await execute(
6✔
167
      HTTPRequest(
6✔
168
        url: configuration.url.appendingPathComponent("object/\(_path)"),
6✔
169
        method: method,
6✔
170
        query: [],
6✔
171
        formData: formData,
6✔
172
        options: options,
6✔
173
        headers: headers
6✔
174
      )
6✔
175
    )
6✔
176
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
6✔
177

6✔
178
    return FileUploadResponse(
6✔
179
      id: response.Id,
6✔
180
      path: cleanPath,
6✔
181
      fullPath: response.Key
6✔
182
    )
6✔
183
  }
6✔
184

185
  /// Uploads a file to an existing bucket.
186
  ///
187
  /// ```swift
188
  /// let response = try await storage.from("avatars").upload("user123.png", data: imageData)
189
  /// print(response.fullPath) // "avatars/user123.png"
190
  /// ```
191
  ///
192
  /// - Parameters:
193
  ///   - path: The relative file path within the bucket, e.g. `"folder/subfolder/filename.png"`.
194
  ///     The bucket must already exist before attempting to upload.
195
  ///   - data: The raw bytes to store in the bucket.
196
  ///   - options: Upload options such as cache control, content type, and upsert behavior.
197
  /// - Returns: A ``FileUploadResponse`` containing the stored object's identifier and path.
198
  /// - Throws: ``StorageError`` if the upload fails or the caller is not authorized.
199
  @discardableResult
200
  public func upload(
201
    _ path: String,
202
    data: Data,
203
    options: FileOptions = FileOptions()
204
  ) async throws -> FileUploadResponse {
2✔
205
    try await _uploadOrUpdate(
2✔
206
      method: .post,
2✔
207
      path: path,
2✔
208
      file: .data(data),
2✔
209
      options: options
2✔
210
    )
2✔
211
  }
2✔
212

213
  /// Uploads a file from a local file URL to an existing bucket.
214
  ///
215
  /// Use this overload when you have a `URL` pointing to a file on disk rather than raw `Data`
216
  /// already loaded in memory. This is preferable for large files because the data is streamed
217
  /// rather than read entirely into memory first.
218
  ///
219
  /// - Parameters:
220
  ///   - path: The relative file path within the bucket, e.g. `"folder/subfolder/filename.png"`.
221
  ///     The bucket must already exist before attempting to upload.
222
  ///   - fileURL: A `file://` URL pointing to the local file to upload.
223
  ///   - options: Upload options such as cache control, content type, and upsert behavior.
224
  /// - Returns: A ``FileUploadResponse`` containing the stored object's identifier and path.
225
  /// - Throws: ``StorageError`` if the upload fails or the caller is not authorized.
226
  @discardableResult
227
  public func upload(
228
    _ path: String,
229
    fileURL: URL,
230
    options: FileOptions = FileOptions()
231
  ) async throws -> FileUploadResponse {
2✔
232
    try await _uploadOrUpdate(
2✔
233
      method: .post,
2✔
234
      path: path,
2✔
235
      file: .url(fileURL),
2✔
236
      options: options
2✔
237
    )
2✔
238
  }
2✔
239

240
  /// Replaces an existing file at the specified path with new data.
241
  ///
242
  /// Unlike ``upload(_:data:options:)`` with `upsert: true`, this method always targets an
243
  /// existing object and will throw if the path does not exist.
244
  ///
245
  /// - Parameters:
246
  ///   - path: The relative file path within the bucket, e.g. `"folder/subfolder/filename.png"`.
247
  ///     The bucket must already exist before attempting to update.
248
  ///   - data: The raw bytes to overwrite the existing file with.
249
  ///   - options: Upload options such as cache control and content type.
250
  /// - Returns: A ``FileUploadResponse`` containing the updated object's identifier and path.
251
  /// - Throws: ``StorageError`` if the path does not exist or the caller is not authorized.
252
  @discardableResult
253
  public func update(
254
    _ path: String,
255
    data: Data,
256
    options: FileOptions = FileOptions()
257
  ) async throws -> FileUploadResponse {
1✔
258
    try await _uploadOrUpdate(
1✔
259
      method: .put,
1✔
260
      path: path,
1✔
261
      file: .data(data),
1✔
262
      options: options
1✔
263
    )
1✔
264
  }
1✔
265

266
  /// Replaces an existing file at the specified path with a new local file.
267
  ///
268
  /// Use this overload when the replacement content is available as a `URL` pointing to a file on
269
  /// disk rather than raw `Data`. Large files are streamed rather than fully loaded into memory.
270
  ///
271
  /// - Parameters:
272
  ///   - path: The relative file path within the bucket, e.g. `"folder/subfolder/filename.png"`.
273
  ///     The bucket must already exist before attempting to update.
274
  ///   - fileURL: A `file://` URL pointing to the local file to use as the replacement.
275
  ///   - options: Upload options such as cache control and content type.
276
  /// - Returns: A ``FileUploadResponse`` containing the updated object's identifier and path.
277
  /// - Throws: ``StorageError`` if the path does not exist or the caller is not authorized.
278
  @discardableResult
279
  public func update(
280
    _ path: String,
281
    fileURL: URL,
282
    options: FileOptions = FileOptions()
283
  ) async throws -> FileUploadResponse {
1✔
284
    try await _uploadOrUpdate(
1✔
285
      method: .put,
1✔
286
      path: path,
1✔
287
      file: .url(fileURL),
1✔
288
      options: options
1✔
289
    )
1✔
290
  }
1✔
291

292
  /// Moves an existing file to a new path, optionally within a different bucket.
293
  ///
294
  /// ```swift
295
  /// try await storage.from("docs").move(from: "draft.pdf", to: "published/report.pdf")
296
  /// ```
297
  ///
298
  /// - Parameters:
299
  ///   - source: The original file path including the file name, e.g. `"folder/image.png"`.
300
  ///   - destination: The new file path including the new file name, e.g. `"archive/image.png"`.
301
  ///   - options: Optional ``DestinationOptions`` specifying a destination bucket. When `nil`,
302
  ///     the file is moved within the same bucket.
303
  /// - Throws: ``StorageError`` if the source does not exist or the caller is not authorized.
304
  public func move(
305
    from source: String,
306
    to destination: String,
307
    options: DestinationOptions? = nil
308
  ) async throws {
3✔
309
    try await execute(
3✔
310
      HTTPRequest(
3✔
311
        url: configuration.url.appendingPathComponent("object/move"),
3✔
312
        method: .post,
3✔
313
        body: configuration.encoder.encode(
3✔
314
          [
3✔
315
            "bucketId": bucketId,
3✔
316
            "sourceKey": source,
3✔
317
            "destinationKey": destination,
3✔
318
            "destinationBucket": options?.destinationBucket,
3✔
319
          ]
3✔
320
        )
3✔
321
      )
3✔
322
    )
3✔
323
  }
1✔
324

325
  /// Copies an existing file to a new path, optionally within a different bucket.
326
  ///
327
  /// ```swift
328
  /// let newPath = try await storage.from("docs").copy(from: "original.pdf", to: "backup/original.pdf")
329
  /// ```
330
  ///
331
  /// - Parameters:
332
  ///   - source: The original file path including the file name, e.g. `"folder/image.png"`.
333
  ///   - destination: The destination path including the new file name, e.g. `"folder/image-copy.png"`.
334
  ///   - options: Optional ``DestinationOptions`` specifying a destination bucket. When `nil`,
335
  ///     the file is copied within the same bucket.
336
  /// - Returns: The full storage path of the newly created copy.
337
  /// - Throws: ``StorageError`` if the source does not exist or the caller is not authorized.
338
  @discardableResult
339
  public func copy(
340
    from source: String,
341
    to destination: String,
342
    options: DestinationOptions? = nil
343
  ) async throws -> String {
1✔
344
    struct UploadResponse: Decodable {
1✔
345
      let Key: String
1✔
346
    }
1✔
347

1✔
348
    return try await execute(
1✔
349
      HTTPRequest(
1✔
350
        url: configuration.url.appendingPathComponent("object/copy"),
1✔
351
        method: .post,
1✔
352
        body: configuration.encoder.encode(
1✔
353
          [
1✔
354
            "bucketId": bucketId,
1✔
355
            "sourceKey": source,
1✔
356
            "destinationKey": destination,
1✔
357
            "destinationBucket": options?.destinationBucket,
1✔
358
          ]
1✔
359
        )
1✔
360
      )
1✔
361
    )
1✔
362
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
1✔
363
    .Key
1✔
364
  }
1✔
365

366
  /// Creates a signed URL for sharing a private file for a fixed period of time.
367
  ///
368
  /// - Parameters:
369
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
370
  ///   - expiresIn: Seconds until the URL expires, e.g. `60` for one minute.
371
  ///   - download: An optional custom download filename. Pass a non-nil string to force a download
372
  ///     with that filename in the `Content-Disposition` header, or `nil` for inline display.
373
  ///   - transform: Optional image transformation options applied server-side before delivery.
374
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
375
  ///     cache-busting purposes.
376
  /// - Returns: A signed `URL` ready to share.
377
  /// - Throws: ``StorageError`` if the path does not exist or the caller is not authorized.
378
  @_disfavoredOverload
379
  public func createSignedURL(
380
    path: String,
381
    expiresIn: Int,
382
    download: String? = nil,
383
    transform: TransformOptions? = nil,
384
    cacheNonce: String? = nil
385
  ) async throws -> URL {
3✔
386
    struct Body: Encodable {
3✔
387
      let expiresIn: Int
3✔
388
      let transform: TransformOptions?
3✔
389
    }
3✔
390

3✔
391
    let encoder = JSONEncoder.unconfiguredEncoder
3✔
392

3✔
393
    let response = try await execute(
3✔
394
      HTTPRequest(
3✔
395
        url: configuration.url.appendingPathComponent("object/sign/\(_getFinalPath(path))"),
3✔
396
        method: .post,
3✔
397
        body: encoder.encode(
3✔
398
          Body(expiresIn: expiresIn, transform: transform)
3✔
399
        )
3✔
400
      )
3✔
401
    )
3✔
402
    .decoded(as: SignedURLAPIResponse.self, decoder: configuration.decoder)
3✔
403

3✔
404
    return try makeSignedURL(response.signedURL, download: download, cacheNonce: cacheNonce)
3✔
405
  }
3✔
406

407
  /// Creates a signed URL for sharing a private file for a fixed period of time.
408
  ///
409
  /// ```swift
410
  /// // Inline preview URL, valid for 5 minutes
411
  /// let url = try await storage.from("docs").createSignedURL(path: "report.pdf", expiresIn: 300)
412
  ///
413
  /// // Force download with original file name
414
  /// let downloadURL = try await storage.from("docs").createSignedURL(
415
  ///   path: "report.pdf",
416
  ///   expiresIn: 60,
417
  ///   download: .withOriginalName
418
  /// )
419
  /// ```
420
  ///
421
  /// - Parameters:
422
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
423
  ///   - expiresIn: Seconds until the URL expires, e.g. `60` for one minute.
424
  ///   - download: Controls whether the URL triggers a file download. Pass `.withOriginalName` to
425
  ///     download using the file's original name, `.named("custom.pdf")` for a custom name, or
426
  ///     `nil` for inline display.
427
  ///   - transform: Optional image transformation options applied server-side before delivery.
428
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
429
  ///     cache-busting purposes.
430
  /// - Returns: A signed `URL` ready to share.
431
  /// - Throws: ``StorageError`` if the path does not exist or the caller is not authorized.
432
  public func createSignedURL(
433
    path: String,
434
    expiresIn: Int,
435
    download: DownloadBehavior? = nil,
436
    transform: TransformOptions? = nil,
437
    cacheNonce: String? = nil
438
  ) async throws -> URL {
3✔
439
    try await createSignedURL(
3✔
440
      path: path,
3✔
441
      expiresIn: expiresIn,
3✔
442
      download: download?.queryValue,
3✔
443
      transform: transform,
3✔
444
      cacheNonce: cacheNonce
3✔
445
    )
3✔
446
  }
3✔
447

448
  /// Creates signed URLs for multiple files in a single request.
449
  ///
450
  /// Each element in the returned array is a ``SignedURLResult``: either
451
  /// `.success(path:signedURL:)` or `.failure(path:error:)`. Exactly one case applies per item.
452
  /// Paths that do not exist produce a `.failure` result rather than throwing.
453
  ///
454
  /// - Parameters:
455
  ///   - paths: File paths to sign, e.g. `["folder/image.png", "folder2/image2.png"]`.
456
  ///   - expiresIn: Seconds until the URLs expire, e.g. `60` for one minute.
457
  ///   - download: An optional custom download filename. Pass a non-nil string to force a download,
458
  ///     or `nil` for inline display.
459
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
460
  ///     cache-busting purposes.
461
  /// - Returns: An array of ``SignedURLResult`` values, one per requested path.
462
  /// - Throws: ``StorageError`` if the request itself fails (e.g. unauthorized). Individual missing
463
  ///   paths are reported as ``SignedURLResult/failure(path:error:)`` rather than thrown.
464
  @_disfavoredOverload
465
  public func createSignedURLs(
466
    paths: [String],
467
    expiresIn: Int,
468
    download: String? = nil,
469
    cacheNonce: String? = nil
470
  ) async throws -> [SignedURLResult] {
5✔
471
    struct Params: Encodable {
5✔
472
      let expiresIn: Int
5✔
473
      let paths: [String]
5✔
474
    }
5✔
475

5✔
476
    let encoder = JSONEncoder.unconfiguredEncoder
5✔
477

5✔
478
    let response = try await execute(
5✔
479
      HTTPRequest(
5✔
480
        url: configuration.url.appendingPathComponent("object/sign/\(bucketId)"),
5✔
481
        method: .post,
5✔
482
        body: encoder.encode(
5✔
483
          Params(expiresIn: expiresIn, paths: paths)
5✔
484
        )
5✔
485
      )
5✔
486
    )
5✔
487
    .decoded(as: [SignedURLsAPIResponse].self, decoder: configuration.decoder)
5✔
488

5✔
489
    return try response.map { item in
9✔
490
      if let signedURLString = item.signedURL {
9✔
491
        let url = try makeSignedURL(signedURLString, download: download, cacheNonce: cacheNonce)
8✔
492
        return .success(path: item.path, signedURL: url)
8✔
493
      } else {
8✔
494
        return .failure(path: item.path, error: item.error ?? "Unknown error")
1✔
495
      }
1✔
496
    }
9✔
497
  }
5✔
498

499
  /// Creates signed URLs for multiple files in a single request.
500
  ///
501
  /// Each element in the returned array is a ``SignedURLResult``: either
502
  /// `.success(path:signedURL:)` or `.failure(path:error:)`. Exactly one case applies per item.
503
  /// Paths that do not exist produce a `.failure` result rather than throwing.
504
  ///
505
  /// ```swift
506
  /// let results = try await storage.from("docs").createSignedURLs(
507
  ///   paths: ["a.pdf", "b.pdf", "missing.pdf"],
508
  ///   expiresIn: 3600
509
  /// )
510
  /// for result in results {
511
  ///   switch result {
512
  ///   case .success(let path, let url): print(path, url)
513
  ///   case .failure(let path, let error): print(path, "failed:", error)
514
  ///   }
515
  /// }
516
  /// ```
517
  ///
518
  /// - Parameters:
519
  ///   - paths: File paths to sign, e.g. `["folder/image.png", "folder2/image2.png"]`.
520
  ///   - expiresIn: Seconds until the URLs expire, e.g. `60` for one minute.
521
  ///   - download: Controls whether the URLs trigger a file download. Pass `.withOriginalName` to
522
  ///     download using each file's original name, `.named("custom")` for a custom name, or `nil`
523
  ///     for inline display.
524
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
525
  ///     cache-busting purposes.
526
  /// - Returns: An array of ``SignedURLResult`` values, one per requested path, preserving order.
527
  /// - Throws: ``StorageError`` if the request itself fails (e.g. unauthorized). Individual missing
528
  ///   paths are reported as ``SignedURLResult/failure(path:error:)`` rather than thrown.
529
  public func createSignedURLs(
530
    paths: [String],
531
    expiresIn: Int,
532
    download: DownloadBehavior? = nil,
533
    cacheNonce: String? = nil
534
  ) async throws -> [SignedURLResult] {
5✔
535
    try await createSignedURLs(
5✔
536
      paths: paths,
5✔
537
      expiresIn: expiresIn,
5✔
538
      download: download?.queryValue,
5✔
539
      cacheNonce: cacheNonce
5✔
540
    )
5✔
541
  }
5✔
542

543
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
544
  ///
545
  /// Each item in the returned array is a ``SignedURLResult``: either `.success(path:signedURL:)` or
546
  /// `.failure(path:error:)`. Exactly one case is guaranteed per item.
547
  /// - Parameters:
548
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
549
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
550
  ///   - download: Trigger a download with the default file name.
551
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
552
  private func makeSignedURL(_ signedURL: String, download: String?, cacheNonce: String? = nil)
553
    throws -> URL
554
  {
14✔
555
    guard let signedURLComponents = URLComponents(string: signedURL),
14✔
556
      var baseComponents = URLComponents(
14✔
557
        url: configuration.url, resolvingAgainstBaseURL: false)
14✔
558
    else {
14✔
UNCOV
559
      throw URLError(.badURL)
×
560
    }
14✔
561

14✔
562
    baseComponents.path +=
14✔
563
      signedURLComponents.path.hasPrefix("/")
14✔
564
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
14✔
565
    baseComponents.queryItems = signedURLComponents.queryItems
14✔
566

14✔
567
    if let download {
14✔
568
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
569
      baseComponents.queryItems!.append(URLQueryItem(name: "download", value: download))
3✔
570
    }
3✔
571

14✔
572
    if let cacheNonce {
14✔
573
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
574
      baseComponents.queryItems!.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
2✔
575
    }
2✔
576

14✔
577
    guard let signedURL = baseComponents.url else {
14✔
UNCOV
578
      throw URLError(.badURL)
×
579
    }
14✔
580

14✔
581
    return signedURL
14✔
582
  }
14✔
583

584
  /// Deletes one or more files from the bucket.
585
  ///
586
  /// ```swift
587
  /// let removed = try await storage.from("avatars").remove(paths: ["user123.png", "temp/draft.png"])
588
  /// ```
589
  ///
590
  /// - Parameter paths: File paths to delete, including the file name,
591
  ///   e.g. `["folder/image.png", "other/doc.pdf"]`.
592
  /// - Returns: An array of ``FileObject`` values representing the files that were removed.
593
  /// - Throws: ``StorageError`` if the request fails or the caller is not authorized.
594
  @discardableResult
595
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
596
    try await execute(
1✔
597
      HTTPRequest(
1✔
598
        url: configuration.url.appendingPathComponent("object/\(bucketId)"),
1✔
599
        method: .delete,
1✔
600
        body: configuration.encoder.encode(["prefixes": paths])
1✔
601
      )
1✔
602
    )
1✔
603
    .decoded(decoder: configuration.decoder)
1✔
604
  }
1✔
605

606
  /// Lists all files within a bucket folder.
607
  ///
608
  /// ```swift
609
  /// let files = try await storage.from("avatars").list(path: "users/")
610
  /// ```
611
  ///
612
  /// - Parameters:
613
  ///   - path: The folder prefix to list, e.g. `"users/"`. Pass `nil` to list the bucket root.
614
  ///   - options: Search options for filtering, sorting, and paginating results. Defaults to the
615
  ///     first 100 files sorted by name ascending.
616
  /// - Returns: An array of ``FileObject`` values representing the matching files and folders.
617
  /// - Throws: ``StorageError`` if the request fails or the caller is not authorized.
618
  public func list(
619
    path: String? = nil,
620
    options: SearchOptions? = nil
621
  ) async throws -> [FileObject] {
12✔
622
    let encoder = JSONEncoder.unconfiguredEncoder
12✔
623

12✔
624
    var options = options ?? defaultSearchOptions
12✔
625
    options.limit = options.limit ?? defaultSearchOptions.limit
12✔
626
    options.offset = options.offset ?? defaultSearchOptions.offset
12✔
627
    options.prefix = path ?? ""
12✔
628

12✔
629
    var sortBy = options.sortBy ?? SortBy()
12✔
630
    sortBy.column = sortBy.column ?? defaultSearchOptions.sortBy?.column
12✔
631
    sortBy.order = sortBy.order ?? defaultSearchOptions.sortBy?.order
12✔
632
    options.sortBy = sortBy
12✔
633

12✔
634
    return try await execute(
12✔
635
      HTTPRequest(
12✔
636
        url: configuration.url.appendingPathComponent("object/list/\(bucketId)"),
12✔
637
        method: .post,
12✔
638
        body: encoder.encode(options)
12✔
639
      )
12✔
640
    )
12✔
641
    .decoded(decoder: configuration.decoder)
12✔
642
  }
12✔
643

644
  /// Downloads a file from a private bucket and returns its raw bytes.
645
  ///
646
  /// For public buckets, prefer requesting the URL returned by
647
  /// ``getPublicURL(path:download:options:cacheNonce:)`` directly.
648
  ///
649
  /// ```swift
650
  /// let data = try await storage.from("avatars").download(path: "user123.png")
651
  /// let image = UIImage(data: data)
652
  /// ```
653
  ///
654
  /// - Parameters:
655
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
656
  ///   - options: Optional image transformation options applied server-side before delivery.
657
  ///   - additionalQueryItems: Extra URL query items appended to the request.
658
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
659
  ///     cache-busting purposes.
660
  /// - Returns: The raw file data.
661
  /// - Throws: ``StorageError`` if the file does not exist or the caller is not authorized.
662
  @discardableResult
663
  public func download(
664
    path: String,
665
    options: TransformOptions? = nil,
666
    query additionalQueryItems: [URLQueryItem]? = nil,
667
    cacheNonce: String? = nil
668
  ) async throws -> Data {
6✔
669
    var queryItems = options?.queryItems ?? []
6✔
670
    let renderPath = options.map { !$0.isEmpty } == true ? "render/image/authenticated" : "object"
6✔
671
    let _path = _getFinalPath(path)
6✔
672

6✔
673
    if let additionalQueryItems {
6✔
674
      queryItems.append(contentsOf: additionalQueryItems)
1✔
675
    }
1✔
676

6✔
677
    if let cacheNonce {
6✔
678
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
679
    }
1✔
680

6✔
681
    return try await execute(
6✔
682
      HTTPRequest(
6✔
683
        url: configuration.url
6✔
684
          .appendingPathComponent("\(renderPath)/\(_path)"),
6✔
685
        method: .get,
6✔
686
        query: queryItems
6✔
687
      )
6✔
688
    )
6✔
689
    .data
6✔
690
  }
6✔
691

692
  /// Retrieves metadata about an existing file without downloading its content.
693
  ///
694
  /// - Parameter path: The file path including the file name, e.g. `"folder/image.png"`.
695
  /// - Returns: A ``FileObjectV2`` containing size, content type, ETag, and other metadata.
696
  /// - Throws: ``StorageError`` if the file does not exist or the caller is not authorized.
697
  public func info(path: String) async throws -> FileObjectV2 {
1✔
698
    let _path = _getFinalPath(path)
1✔
699

1✔
700
    return try await execute(
1✔
701
      HTTPRequest(
1✔
702
        url: configuration.url.appendingPathComponent("object/info/\(_path)"),
1✔
703
        method: .get
1✔
704
      )
1✔
705
    )
1✔
706
    .decoded(decoder: configuration.decoder)
1✔
707
  }
1✔
708

709
  /// Checks whether a file exists in the bucket without downloading it.
710
  ///
711
  /// Returns `false` for HTTP 400 and 404 responses, and re-throws for any other error.
712
  ///
713
  /// - Parameter path: The file path including the file name, e.g. `"folder/image.png"`.
714
  /// - Returns: `true` if the file exists and is accessible, `false` if it does not exist.
715
  /// - Throws: ``StorageError`` for errors other than "not found" (e.g. network failure or
716
  ///   authorization errors).
717
  public func exists(path: String) async throws -> Bool {
3✔
718
    do {
3✔
719
      try await execute(
3✔
720
        HTTPRequest(
3✔
721
          url: configuration.url.appendingPathComponent("object/\(_getFinalPath(path))"),
3✔
722
          method: .head
3✔
723
        )
3✔
724
      )
3✔
725
      return true
1✔
726
    } catch {
3✔
727
      var statusCode: Int?
2✔
728

2✔
729
      if let error = error as? StorageError {
2✔
UNCOV
730
        statusCode = error.statusCode.flatMap(Int.init)
×
731
      } else if let error = error as? HTTPError {
2✔
732
        statusCode = error.response.statusCode
2✔
733
      }
2✔
734

2✔
735
      if let statusCode, [400, 404].contains(statusCode) {
2✔
736
        return false
2✔
737
      }
2✔
UNCOV
738

×
UNCOV
739
      throw error
×
740
    }
2✔
741
  }
3✔
742

743
  /// Returns the public URL for a file in a public bucket.
744
  ///
745
  /// > Note: The bucket must be set to public for this URL to be accessible without authentication.
746
  ///
747
  /// - Parameters:
748
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
749
  ///   - download: An optional custom download filename. Pass a non-nil string to force a download
750
  ///     with that name, or `nil` for inline display.
751
  ///   - options: Optional image transformation options applied server-side before delivery.
752
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
753
  ///     cache-busting purposes.
754
  /// - Returns: The publicly accessible `URL` for the file.
755
  /// - Throws: `URLError` if the resulting URL cannot be constructed.
756
  @_disfavoredOverload
757
  public func getPublicURL(
758
    path: String,
759
    download: String? = nil,
760
    options: TransformOptions? = nil,
761
    cacheNonce: String? = nil
762
  ) throws -> URL {
12✔
763
    var queryItems: [URLQueryItem] = []
12✔
764

12✔
765
    guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true)
12✔
766
    else {
12✔
UNCOV
767
      throw URLError(.badURL)
×
768
    }
12✔
769

12✔
770
    if let download {
12✔
771
      queryItems.append(URLQueryItem(name: "download", value: download))
5✔
772
    }
5✔
773

12✔
774
    if let optionsQueryItems = options?.queryItems {
12✔
775
      queryItems.append(contentsOf: optionsQueryItems)
3✔
776
    }
3✔
777

12✔
778
    if let cacheNonce {
12✔
779
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
780
    }
1✔
781

12✔
782
    let renderPath = options.map { !$0.isEmpty } == true ? "render/image" : "object"
12✔
783

12✔
784
    components.path += "/\(renderPath)/public/\(_getFinalPath(path))"
12✔
785
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
12✔
786

12✔
787
    guard let generatedUrl = components.url else {
12✔
UNCOV
788
      throw URLError(.badURL)
×
789
    }
12✔
790

12✔
791
    return generatedUrl
12✔
792
  }
12✔
793

794
  /// Returns the public URL for a file in a public bucket.
795
  ///
796
  /// ```swift
797
  /// // Inline display URL
798
  /// let url = try storage.from("avatars").getPublicURL(path: "user123.png")
799
  ///
800
  /// // Force download with original file name
801
  /// let dlURL = try storage.from("docs").getPublicURL(path: "report.pdf", download: .withOriginalName)
802
  /// ```
803
  ///
804
  /// > Note: The bucket must be set to public for this URL to be accessible without authentication.
805
  ///
806
  /// - Parameters:
807
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
808
  ///   - download: Controls whether the URL triggers a file download. Pass `.withOriginalName` to
809
  ///     download using the file's original name, `.named("custom.pdf")` for a custom name, or
810
  ///     `nil` for inline display.
811
  ///   - options: Optional image transformation options applied server-side before delivery.
812
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
813
  ///     cache-busting purposes.
814
  /// - Returns: The publicly accessible `URL` for the file.
815
  /// - Throws: `URLError` if the resulting URL cannot be constructed.
816
  public func getPublicURL(
817
    path: String,
818
    download: DownloadBehavior? = nil,
819
    options: TransformOptions? = nil,
820
    cacheNonce: String? = nil
821
  ) throws -> URL {
10✔
822
    try getPublicURL(
10✔
823
      path: path,
10✔
824
      download: download?.queryValue,
10✔
825
      options: options,
10✔
826
      cacheNonce: cacheNonce
10✔
827
    )
10✔
828
  }
10✔
829

830
  /// Creates a signed upload URL that allows uploading a file without further authentication.
831
  ///
832
  /// Signed upload URLs are valid for 2 hours. Pass the returned ``SignedUploadURL/token`` to
833
  /// ``uploadToSignedURL(_:token:data:options:)`` (or the file-URL variant) to perform the upload.
834
  ///
835
  /// ```swift
836
  /// let signedUpload = try await storage.from("avatars").createSignedUploadURL(path: "user123.png")
837
  /// // Share signedUpload.token with the uploader
838
  /// try await storage.from("avatars").uploadToSignedURL(
839
  ///   "user123.png",
840
  ///   token: signedUpload.token,
841
  ///   data: imageData
842
  /// )
843
  /// ```
844
  ///
845
  /// - Parameters:
846
  ///   - path: The destination file path including the file name, e.g. `"folder/image.png"`.
847
  ///   - options: Optional ``CreateSignedUploadURLOptions`` controlling upsert behavior.
848
  /// - Returns: A ``SignedUploadURL`` containing the signed URL and an upload token.
849
  /// - Throws: ``StorageError`` if the request fails or the caller is not authorized.
850
  public func createSignedUploadURL(
851
    path: String,
852
    options: CreateSignedUploadURLOptions? = nil
853
  ) async throws -> SignedUploadURL {
3✔
854
    struct Response: Decodable {
3✔
855
      let url: String
3✔
856
    }
3✔
857

3✔
858
    var headers = HTTPFields()
3✔
859
    if let upsert = options?.upsert, upsert {
3✔
860
      headers[.xUpsert] = "true"
1✔
861
    }
1✔
862

3✔
863
    let cleanPath = _removeEmptyFolders(path)
3✔
864

3✔
865
    let response = try await execute(
3✔
866
      HTTPRequest(
3✔
867
        url: configuration.url.appendingPathComponent(
3✔
868
          "object/upload/sign/\(bucketId)/\(cleanPath)"),
3✔
869
        method: .post,
3✔
870
        headers: headers
3✔
871
      )
3✔
872
    )
3✔
873
    .decoded(as: Response.self, decoder: configuration.decoder)
3✔
874

3✔
875
    let signedURL = try makeSignedURL(response.url, download: nil)
3✔
876

3✔
877
    guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
3✔
UNCOV
878
      throw URLError(.badURL)
×
879
    }
3✔
880

3✔
881
    guard let token = components.queryItems?.first(where: { $0.name == "token" })?.value else {
3✔
UNCOV
882
      throw StorageError(statusCode: nil, message: "No token returned by API", error: nil)
×
883
    }
3✔
884

3✔
885
    guard let url = components.url else {
3✔
UNCOV
886
      throw URLError(.badURL)
×
887
    }
3✔
888

3✔
889
    return SignedUploadURL(
3✔
890
      signedURL: url,
3✔
891
      path: cleanPath,
3✔
892
      token: token
3✔
893
    )
3✔
894
  }
3✔
895

896
  /// Uploads raw data to a pre-signed upload URL.
897
  ///
898
  /// Obtain the `token` from ``createSignedUploadURL(path:options:)`` before calling this method.
899
  ///
900
  /// - Parameters:
901
  ///   - path: The destination file path, e.g. `"folder/subfolder/filename.png"`.
902
  ///     The bucket must already exist.
903
  ///   - token: The upload token from ``createSignedUploadURL(path:options:)``.
904
  ///   - data: The raw bytes to store in the bucket.
905
  ///   - options: Optional upload options such as cache control and content type.
906
  /// - Returns: A ``SignedURLUploadResponse`` containing the stored object path.
907
  /// - Throws: ``StorageError`` if the token is invalid, expired, or the upload fails.
908
  @discardableResult
909
  public func uploadToSignedURL(
910
    _ path: String,
911
    token: String,
912
    data: Data,
913
    options: FileOptions? = nil
914
  ) async throws -> SignedURLUploadResponse {
2✔
915
    try await _uploadToSignedURL(
2✔
916
      path: path,
2✔
917
      token: token,
2✔
918
      file: .data(data),
2✔
919
      options: options
2✔
920
    )
2✔
921
  }
2✔
922

923
  /// Uploads a local file to a pre-signed upload URL.
924
  ///
925
  /// Obtain the `token` from ``createSignedUploadURL(path:options:)`` before calling this method.
926
  /// Use this overload for large files where streaming from disk is preferable to loading all
927
  /// content into memory.
928
  ///
929
  /// - Parameters:
930
  ///   - path: The destination file path, e.g. `"folder/subfolder/filename.png"`.
931
  ///     The bucket must already exist.
932
  ///   - token: The upload token from ``createSignedUploadURL(path:options:)``.
933
  ///   - fileURL: A `file://` URL pointing to the local file to upload.
934
  ///   - options: Optional upload options such as cache control and content type.
935
  /// - Returns: A ``SignedURLUploadResponse`` containing the stored object path.
936
  /// - Throws: ``StorageError`` if the token is invalid, expired, or the upload fails.
937
  @discardableResult
938
  public func uploadToSignedURL(
939
    _ path: String,
940
    token: String,
941
    fileURL: URL,
942
    options: FileOptions? = nil
943
  ) async throws -> SignedURLUploadResponse {
1✔
944
    try await _uploadToSignedURL(
1✔
945
      path: path,
1✔
946
      token: token,
1✔
947
      file: .url(fileURL),
1✔
948
      options: options
1✔
949
    )
1✔
950
  }
1✔
951

952
  private func _uploadToSignedURL(
953
    path: String,
954
    token: String,
955
    file: FileUpload,
956
    options: FileOptions?
957
  ) async throws -> SignedURLUploadResponse {
3✔
958
    let options = options ?? defaultFileOptions
3✔
959
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
3✔
960

3✔
961
    headers[.xUpsert] = "\(options.upsert)"
3✔
962
    headers[.duplex] = options.duplex
3✔
963

3✔
964
    #if DEBUG
965
      let formData = MultipartFormData(boundary: testingBoundary.value)
3✔
966
    #else
967
      let formData = MultipartFormData()
968
    #endif
969
    file.encode(to: formData, withPath: path, options: options)
3✔
970

3✔
971
    struct UploadResponse: Decodable {
3✔
972
      let Key: String
3✔
973
    }
3✔
974

3✔
975
    let cleanPath = _removeEmptyFolders(path)
3✔
976

3✔
977
    let fullPath = try await execute(
3✔
978
      HTTPRequest(
3✔
979
        url: configuration.url
3✔
980
          .appendingPathComponent("object/upload/sign/\(bucketId)/\(cleanPath)"),
3✔
981
        method: .put,
3✔
982
        query: [URLQueryItem(name: "token", value: token)],
3✔
983
        formData: formData,
3✔
984
        options: options,
3✔
985
        headers: headers
3✔
986
      )
3✔
987
    )
3✔
988
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
3✔
989
    .Key
3✔
990

3✔
991
    return SignedURLUploadResponse(path: cleanPath, fullPath: fullPath)
3✔
992
  }
3✔
993

994
  private func _getFinalPath(_ path: String) -> String {
31✔
995
    let strippedPath = path.replacingOccurrences(
31✔
996
      of: "^/+", with: "", options: .regularExpression
31✔
997
    )
31✔
998
    return "\(bucketId)/\(strippedPath)"
31✔
999
  }
31✔
1000

1001
  private func _removeEmptyFolders(_ path: String) -> String {
12✔
1002
    let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
12✔
1003
    let cleanedPath = trimmedPath.replacingOccurrences(
12✔
1004
      of: "/+", with: "/", options: .regularExpression
12✔
1005
    )
12✔
1006
    return cleanedPath
12✔
1007
  }
12✔
1008
}
1009

1010
extension HTTPField.Name {
1011
  static let duplex = Self("duplex")!
1012
  static let xUpsert = Self("x-upsert")!
1013
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc