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

supabase / supabase-swift / 28785872053

06 Jul 2026 10:44AM UTC coverage: 80.82% (-0.1%) from 80.957%
28785872053

push

github

web-flow
feat: raise minimum platform versions to iOS 16 and equivalents (#1067)

Bumps Package.swift platform minimums to iOS 16, macCatalyst 16,
macOS 13 (Ventura), watchOS 9, and tvOS 16 — the versions that shipped
together in the iOS 16 generation. visionOS stays at 1.0+ since its
first release already postdates iOS 16. Updates README.md/AGENTS.md
to match.

Stacked on top of the Swift 6.1 minimum-version bump.

7652 of 9468 relevant lines covered (80.82%)

37.0 hits per line

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

97.78
/Sources/Storage/StorageFileApi.swift
1
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) {
6✔
28
    formData.append(
6✔
29
      options.cacheControl.data(using: .utf8)!,
6✔
30
      withName: "cacheControl"
6✔
31
    )
6✔
32

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

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

6✔
46
    case .url(let url):
6✔
47
      formData.append(url, withName: "")
3✔
48
    }
6✔
49
  }
6✔
50
}
51

52
#if DEBUG
53
  import ConcurrencyExtras
54
  let testingBoundary = LockIsolated<String?>(nil)
1✔
55
#endif
56

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

112
  init(bucketId: String, configuration: StorageClientConfiguration) {
47✔
113
    self.bucketId = bucketId
47✔
114
    super.init(configuration: configuration)
47✔
115
  }
47✔
116

117
  private struct MoveResponse: Decodable {
118
    let message: String
119
  }
120

121
  private struct SignedURLAPIResponse: Decodable {
122
    let signedURL: String
123
  }
124

125
  private struct SignedURLsAPIResponse: Decodable {
126
    let signedURL: String?
127
    let path: String
128
    let error: String?
129
  }
130

131
  private func _uploadOrUpdate(
132
    method: HTTPTypes.HTTPRequest.Method,
133
    path: String,
134
    file: FileUpload,
135
    options: FileOptions?
136
  ) async throws -> FileUploadResponse {
4✔
137
    let options = options ?? defaultFileOptions
4✔
138
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
4✔
139

4✔
140
    if method == .post {
4✔
141
      headers[.xUpsert] = "\(options.upsert)"
2✔
142
    }
2✔
143

4✔
144
    headers[.duplex] = options.duplex
4✔
145

4✔
146
    #if DEBUG
147
      let formData = MultipartFormData(boundary: testingBoundary.value)
4✔
148
    #else
149
      let formData = MultipartFormData()
150
    #endif
151
    file.encode(to: formData, withPath: path, options: options)
4✔
152

4✔
153
    struct UploadResponse: Decodable {
4✔
154
      let Key: String
4✔
155
      let Id: String
4✔
156
    }
4✔
157

4✔
158
    let cleanPath = _removeEmptyFolders(path)
4✔
159
    let _path = _getFinalPath(cleanPath)
4✔
160

4✔
161
    let response = try await execute(
4✔
162
      HTTPRequest(
4✔
163
        url: configuration.url.appendingPathComponent("object/\(_path)"),
4✔
164
        method: method,
4✔
165
        query: [],
4✔
166
        formData: formData,
4✔
167
        options: options,
4✔
168
        headers: headers
4✔
169
      )
4✔
170
    )
4✔
171
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
4✔
172

4✔
173
    return FileUploadResponse(
4✔
174
      id: response.Id,
4✔
175
      path: path,
4✔
176
      fullPath: response.Key
4✔
177
    )
4✔
178
  }
4✔
179

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

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

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

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

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

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

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

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

3✔
386
    let encoder = JSONEncoder.unconfiguredEncoder
3✔
387

3✔
388
    let response = try await execute(
3✔
389
      HTTPRequest(
3✔
390
        url: configuration.url.appendingPathComponent("object/sign/\(bucketId)/\(path)"),
3✔
391
        method: .post,
3✔
392
        body: encoder.encode(
3✔
393
          Body(expiresIn: expiresIn, transform: transform)
3✔
394
        )
3✔
395
      )
3✔
396
    )
3✔
397
    .decoded(as: SignedURLAPIResponse.self, decoder: configuration.decoder)
3✔
398

3✔
399
    return try makeSignedURL(response.signedURL, download: download, cacheNonce: cacheNonce)
3✔
400
  }
3✔
401

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

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

5✔
471
    let encoder = JSONEncoder.unconfiguredEncoder
5✔
472

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

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

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

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

13✔
557
    baseComponents.path +=
13✔
558
      signedURLComponents.path.hasPrefix("/")
13✔
559
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
560
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
561

13✔
562
    if let download {
13✔
563
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
564
      baseComponents.queryItems!.append(URLQueryItem(name: "download", value: download))
3✔
565
    }
3✔
566

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

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

13✔
576
    return signedURL
13✔
577
  }
13✔
578

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

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

6✔
619
    var options = options ?? defaultSearchOptions
6✔
620
    options.prefix = path ?? ""
6✔
621

6✔
622
    return try await execute(
6✔
623
      HTTPRequest(
6✔
624
        url: configuration.url.appendingPathComponent("object/list/\(bucketId)"),
6✔
625
        method: .post,
6✔
626
        body: encoder.encode(options)
6✔
627
      )
6✔
628
    )
6✔
629
    .decoded(decoder: configuration.decoder)
6✔
630
  }
6✔
631

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

5✔
661
    if let additionalQueryItems {
5✔
662
      queryItems.append(contentsOf: additionalQueryItems)
1✔
663
    }
1✔
664

5✔
665
    if let cacheNonce {
5✔
666
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
667
    }
1✔
668

5✔
669
    return try await execute(
5✔
670
      HTTPRequest(
5✔
671
        url: configuration.url
5✔
672
          .appendingPathComponent("\(renderPath)/\(_path)"),
5✔
673
        method: .get,
5✔
674
        query: queryItems
5✔
675
      )
5✔
676
    )
5✔
677
    .data
5✔
678
  }
5✔
679

680
  /// Retrieves metadata about an existing file without downloading its content.
681
  ///
682
  /// - Parameter path: The file path including the file name, e.g. `"folder/image.png"`.
683
  /// - Returns: A ``FileObjectV2`` containing size, content type, ETag, and other metadata.
684
  /// - Throws: ``StorageError`` if the file does not exist or the caller is not authorised.
685
  public func info(path: String) async throws -> FileObjectV2 {
1✔
686
    let _path = _getFinalPath(path)
1✔
687

1✔
688
    return try await execute(
1✔
689
      HTTPRequest(
1✔
690
        url: configuration.url.appendingPathComponent("object/info/\(_path)"),
1✔
691
        method: .get
1✔
692
      )
1✔
693
    )
1✔
694
    .decoded(decoder: configuration.decoder)
1✔
695
  }
1✔
696

697
  /// Checks whether a file exists in the bucket without downloading it.
698
  ///
699
  /// Returns `false` for HTTP 400 and 404 responses, and re-throws for any other error.
700
  ///
701
  /// - Parameter path: The file path including the file name, e.g. `"folder/image.png"`.
702
  /// - Returns: `true` if the file exists and is accessible, `false` if it does not exist.
703
  /// - Throws: ``StorageError`` for errors other than "not found" (e.g. network failure or
704
  ///   authorisation errors).
705
  public func exists(path: String) async throws -> Bool {
3✔
706
    do {
3✔
707
      try await execute(
3✔
708
        HTTPRequest(
3✔
709
          url: configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"),
3✔
710
          method: .head
3✔
711
        )
3✔
712
      )
3✔
713
      return true
1✔
714
    } catch {
3✔
715
      var statusCode: Int?
2✔
716

2✔
717
      if let error = error as? StorageError {
2✔
718
        statusCode = error.statusCode.flatMap(Int.init)
×
719
      } else if let error = error as? HTTPError {
2✔
720
        statusCode = error.response.statusCode
2✔
721
      }
2✔
722

2✔
723
      if let statusCode, [400, 404].contains(statusCode) {
2✔
724
        return false
2✔
725
      }
2✔
726

×
727
      throw error
×
728
    }
2✔
729
  }
3✔
730

731
  /// Returns the public URL for a file in a public bucket.
732
  ///
733
  /// > Note: The bucket must be set to public for this URL to be accessible without authentication.
734
  ///
735
  /// - Parameters:
736
  ///   - path: The file path including the file name, e.g. `"folder/image.png"`.
737
  ///   - download: An optional custom download filename. Pass a non-nil string to force a download
738
  ///     with that name, or `nil` for inline display.
739
  ///   - options: Optional image transformation options applied server-side before delivery.
740
  ///   - cacheNonce: An optional nonce appended as a `cacheNonce` query parameter for
741
  ///     cache-busting purposes.
742
  /// - Returns: The publicly accessible `URL` for the file.
743
  /// - Throws: `URLError` if the resulting URL cannot be constructed.
744
  @_disfavoredOverload
745
  public func getPublicURL(
746
    path: String,
747
    download: String? = nil,
748
    options: TransformOptions? = nil,
749
    cacheNonce: String? = nil
750
  ) throws -> URL {
11✔
751
    var queryItems: [URLQueryItem] = []
11✔
752

11✔
753
    guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true)
11✔
754
    else {
11✔
755
      throw URLError(.badURL)
×
756
    }
11✔
757

11✔
758
    if let download {
11✔
759
      queryItems.append(URLQueryItem(name: "download", value: download))
5✔
760
    }
5✔
761

11✔
762
    if let optionsQueryItems = options?.queryItems {
11✔
763
      queryItems.append(contentsOf: optionsQueryItems)
3✔
764
    }
3✔
765

11✔
766
    if let cacheNonce {
11✔
767
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
768
    }
1✔
769

11✔
770
    let renderPath = options.map { !$0.isEmpty } == true ? "render/image" : "object"
11✔
771

11✔
772
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
11✔
773
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
11✔
774

11✔
775
    guard let generatedUrl = components.url else {
11✔
776
      throw URLError(.badURL)
×
777
    }
11✔
778

11✔
779
    return generatedUrl
11✔
780
  }
11✔
781

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

818
  /// Creates a signed upload URL that allows uploading a file without further authentication.
819
  ///
820
  /// Signed upload URLs are valid for 2 hours. Pass the returned ``SignedUploadURL/token`` to
821
  /// ``uploadToSignedURL(_:token:data:options:)`` (or the file-URL variant) to perform the upload.
822
  ///
823
  /// ```swift
824
  /// let signedUpload = try await storage.from("avatars").createSignedUploadURL(path: "user123.png")
825
  /// // Share signedUpload.token with the uploader
826
  /// try await storage.from("avatars").uploadToSignedURL(
827
  ///   "user123.png",
828
  ///   token: signedUpload.token,
829
  ///   data: imageData
830
  /// )
831
  /// ```
832
  ///
833
  /// - Parameters:
834
  ///   - path: The destination file path including the file name, e.g. `"folder/image.png"`.
835
  ///   - options: Optional ``CreateSignedUploadURLOptions`` controlling upsert behaviour.
836
  /// - Returns: A ``SignedUploadURL`` containing the signed URL and an upload token.
837
  /// - Throws: ``StorageError`` if the request fails or the caller is not authorised.
838
  public func createSignedUploadURL(
839
    path: String,
840
    options: CreateSignedUploadURLOptions? = nil
841
  ) async throws -> SignedUploadURL {
2✔
842
    struct Response: Decodable {
2✔
843
      let url: String
2✔
844
    }
2✔
845

2✔
846
    var headers = HTTPFields()
2✔
847
    if let upsert = options?.upsert, upsert {
2✔
848
      headers[.xUpsert] = "true"
1✔
849
    }
1✔
850

2✔
851
    let response = try await execute(
2✔
852
      HTTPRequest(
2✔
853
        url: configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
854
        method: .post,
2✔
855
        headers: headers
2✔
856
      )
2✔
857
    )
2✔
858
    .decoded(as: Response.self, decoder: configuration.decoder)
2✔
859

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

2✔
862
    guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
2✔
863
      throw URLError(.badURL)
×
864
    }
2✔
865

2✔
866
    guard let token = components.queryItems?.first(where: { $0.name == "token" })?.value else {
2✔
867
      throw StorageError(statusCode: nil, message: "No token returned by API", error: nil)
×
868
    }
2✔
869

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

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

881
  /// Uploads raw data to a pre-signed upload URL.
882
  ///
883
  /// Obtain the `token` from ``createSignedUploadURL(path:options:)`` before calling this method.
884
  ///
885
  /// - Parameters:
886
  ///   - path: The destination file path, e.g. `"folder/subfolder/filename.png"`.
887
  ///     The bucket must already exist.
888
  ///   - token: The upload token from ``createSignedUploadURL(path:options:)``.
889
  ///   - data: The raw bytes to store in the bucket.
890
  ///   - options: Optional upload options such as cache control and content type.
891
  /// - Returns: A ``SignedURLUploadResponse`` containing the stored object path.
892
  /// - Throws: ``StorageError`` if the token is invalid, expired, or the upload fails.
893
  @discardableResult
894
  public func uploadToSignedURL(
895
    _ path: String,
896
    token: String,
897
    data: Data,
898
    options: FileOptions? = nil
899
  ) async throws -> SignedURLUploadResponse {
1✔
900
    try await _uploadToSignedURL(
1✔
901
      path: path,
1✔
902
      token: token,
1✔
903
      file: .data(data),
1✔
904
      options: options
1✔
905
    )
1✔
906
  }
1✔
907

908
  /// Uploads a local file to a pre-signed upload URL.
909
  ///
910
  /// Obtain the `token` from ``createSignedUploadURL(path:options:)`` before calling this method.
911
  /// Use this overload for large files where streaming from disk is preferable to loading all
912
  /// content into memory.
913
  ///
914
  /// - Parameters:
915
  ///   - path: The destination file path, e.g. `"folder/subfolder/filename.png"`.
916
  ///     The bucket must already exist.
917
  ///   - token: The upload token from ``createSignedUploadURL(path:options:)``.
918
  ///   - fileURL: A `file://` URL pointing to the local file to upload.
919
  ///   - options: Optional upload options such as cache control and content type.
920
  /// - Returns: A ``SignedURLUploadResponse`` containing the stored object path.
921
  /// - Throws: ``StorageError`` if the token is invalid, expired, or the upload fails.
922
  @discardableResult
923
  public func uploadToSignedURL(
924
    _ path: String,
925
    token: String,
926
    fileURL: URL,
927
    options: FileOptions? = nil
928
  ) async throws -> SignedURLUploadResponse {
1✔
929
    try await _uploadToSignedURL(
1✔
930
      path: path,
1✔
931
      token: token,
1✔
932
      file: .url(fileURL),
1✔
933
      options: options
1✔
934
    )
1✔
935
  }
1✔
936

937
  private func _uploadToSignedURL(
938
    path: String,
939
    token: String,
940
    file: FileUpload,
941
    options: FileOptions?
942
  ) async throws -> SignedURLUploadResponse {
2✔
943
    let options = options ?? defaultFileOptions
2✔
944
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
2✔
945

2✔
946
    headers[.xUpsert] = "\(options.upsert)"
2✔
947
    headers[.duplex] = options.duplex
2✔
948

2✔
949
    #if DEBUG
950
      let formData = MultipartFormData(boundary: testingBoundary.value)
2✔
951
    #else
952
      let formData = MultipartFormData()
953
    #endif
954
    file.encode(to: formData, withPath: path, options: options)
2✔
955

2✔
956
    struct UploadResponse: Decodable {
2✔
957
      let Key: String
2✔
958
    }
2✔
959

2✔
960
    let fullPath = try await execute(
2✔
961
      HTTPRequest(
2✔
962
        url: configuration.url
2✔
963
          .appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
964
        method: .put,
2✔
965
        query: [URLQueryItem(name: "token", value: token)],
2✔
966
        formData: formData,
2✔
967
        options: options,
2✔
968
        headers: headers
2✔
969
      )
2✔
970
    )
2✔
971
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
2✔
972
    .Key
2✔
973

2✔
974
    return SignedURLUploadResponse(path: path, fullPath: fullPath)
2✔
975
  }
2✔
976

977
  private func _getFinalPath(_ path: String) -> String {
10✔
978
    "\(bucketId)/\(path)"
10✔
979
  }
10✔
980

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

990
extension HTTPField.Name {
991
  static let duplex = Self("duplex")!
992
  static let xUpsert = Self("x-upsert")!
993
}
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