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

supabase / supabase-swift / 28930530953

08 Jul 2026 08:59AM UTC coverage: 83.384% (+0.4%) from 82.988%
28930530953

Pull #1043

github

web-flow
Merge 25e45192f into 4187766e2
Pull Request #1043: chore: add SwiftLint 0.65.0 with pinned CI version

3 of 3 new or added lines in 2 files covered. (100.0%)

247 existing lines in 9 files now uncovered.

7879 of 9449 relevant lines covered (83.38%)

37.58 hits per line

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

97.81
/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) {
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) {
53✔
113
    self.bucketId = bucketId
53✔
114
    super.init(configuration: configuration)
53✔
115
  }
53✔
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 behavior.
192
  /// - Returns: A ``FileUploadResponse`` containing the stored object's identifier and path.
193
  /// - Throws: ``StorageError`` if the upload fails or the caller is not authorized.
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 behavior.
219
  /// - Returns: A ``FileUploadResponse`` containing the stored object's identifier and path.
220
  /// - Throws: ``StorageError`` if the upload fails or the caller is not authorized.
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 authorized.
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 authorized.
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 authorized.
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 authorized.
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 authorized.
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 authorized.
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. unauthorized). 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. unauthorized). 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 authorized.
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 authorized.
613
  public func list(
614
    path: String? = nil,
615
    options: SearchOptions? = nil
616
  ) async throws -> [FileObject] {
12✔
617
    let encoder = JSONEncoder.unconfiguredEncoder
12✔
618

12✔
619
    var options = options ?? defaultSearchOptions
12✔
620
    options.limit = options.limit ?? defaultSearchOptions.limit
12✔
621
    options.offset = options.offset ?? defaultSearchOptions.offset
12✔
622
    options.prefix = path ?? ""
12✔
623

12✔
624
    var sortBy = options.sortBy ?? SortBy()
12✔
625
    sortBy.column = sortBy.column ?? defaultSearchOptions.sortBy?.column
12✔
626
    sortBy.order = sortBy.order ?? defaultSearchOptions.sortBy?.order
12✔
627
    options.sortBy = sortBy
12✔
628

12✔
629
    return try await execute(
12✔
630
      HTTPRequest(
12✔
631
        url: configuration.url.appendingPathComponent("object/list/\(bucketId)"),
12✔
632
        method: .post,
12✔
633
        body: encoder.encode(options)
12✔
634
      )
12✔
635
    )
12✔
636
    .decoded(decoder: configuration.decoder)
12✔
637
  }
12✔
638

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

5✔
668
    if let additionalQueryItems {
5✔
669
      queryItems.append(contentsOf: additionalQueryItems)
1✔
670
    }
1✔
671

5✔
672
    if let cacheNonce {
5✔
673
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
674
    }
1✔
675

5✔
676
    return try await execute(
5✔
677
      HTTPRequest(
5✔
678
        url: configuration.url
5✔
679
          .appendingPathComponent("\(renderPath)/\(_path)"),
5✔
680
        method: .get,
5✔
681
        query: queryItems
5✔
682
      )
5✔
683
    )
5✔
684
    .data
5✔
685
  }
5✔
686

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

1✔
695
    return try await execute(
1✔
696
      HTTPRequest(
1✔
697
        url: configuration.url.appendingPathComponent("object/info/\(_path)"),
1✔
698
        method: .get
1✔
699
      )
1✔
700
    )
1✔
701
    .decoded(decoder: configuration.decoder)
1✔
702
  }
1✔
703

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

2✔
724
      if let error = error as? StorageError {
2✔
UNCOV
725
        statusCode = error.statusCode.flatMap(Int.init)
×
726
      } else if let error = error as? HTTPError {
2✔
727
        statusCode = error.response.statusCode
2✔
728
      }
2✔
729

2✔
730
      if let statusCode, [400, 404].contains(statusCode) {
2✔
731
        return false
2✔
732
      }
2✔
UNCOV
733

×
UNCOV
734
      throw error
×
735
    }
2✔
736
  }
3✔
737

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

11✔
760
    guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true)
11✔
761
    else {
11✔
UNCOV
762
      throw URLError(.badURL)
×
763
    }
11✔
764

11✔
765
    if let download {
11✔
766
      queryItems.append(URLQueryItem(name: "download", value: download))
5✔
767
    }
5✔
768

11✔
769
    if let optionsQueryItems = options?.queryItems {
11✔
770
      queryItems.append(contentsOf: optionsQueryItems)
3✔
771
    }
3✔
772

11✔
773
    if let cacheNonce {
11✔
774
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
775
    }
1✔
776

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

11✔
779
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
11✔
780
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
11✔
781

11✔
782
    guard let generatedUrl = components.url else {
11✔
UNCOV
783
      throw URLError(.badURL)
×
784
    }
11✔
785

11✔
786
    return generatedUrl
11✔
787
  }
11✔
788

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

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

2✔
853
    var headers = HTTPFields()
2✔
854
    if let upsert = options?.upsert, upsert {
2✔
855
      headers[.xUpsert] = "true"
1✔
856
    }
1✔
857

2✔
858
    let response = try await execute(
2✔
859
      HTTPRequest(
2✔
860
        url: configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
861
        method: .post,
2✔
862
        headers: headers
2✔
863
      )
2✔
864
    )
2✔
865
    .decoded(as: Response.self, decoder: configuration.decoder)
2✔
866

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

2✔
869
    guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
2✔
UNCOV
870
      throw URLError(.badURL)
×
871
    }
2✔
872

2✔
873
    guard let token = components.queryItems?.first(where: { $0.name == "token" })?.value else {
2✔
UNCOV
874
      throw StorageError(statusCode: nil, message: "No token returned by API", error: nil)
×
875
    }
2✔
876

2✔
877
    guard let url = components.url else {
2✔
UNCOV
878
      throw URLError(.badURL)
×
879
    }
2✔
880

2✔
881
    return SignedUploadURL(
2✔
882
      signedURL: url,
2✔
883
      path: path,
2✔
884
      token: token
2✔
885
    )
2✔
886
  }
2✔
887

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

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

944
  private func _uploadToSignedURL(
945
    path: String,
946
    token: String,
947
    file: FileUpload,
948
    options: FileOptions?
949
  ) async throws -> SignedURLUploadResponse {
2✔
950
    let options = options ?? defaultFileOptions
2✔
951
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
2✔
952

2✔
953
    headers[.xUpsert] = "\(options.upsert)"
2✔
954
    headers[.duplex] = options.duplex
2✔
955

2✔
956
    #if DEBUG
957
      let formData = MultipartFormData(boundary: testingBoundary.value)
2✔
958
    #else
959
      let formData = MultipartFormData()
960
    #endif
961
    file.encode(to: formData, withPath: path, options: options)
2✔
962

2✔
963
    struct UploadResponse: Decodable {
2✔
964
      let Key: String
2✔
965
    }
2✔
966

2✔
967
    let fullPath = try await execute(
2✔
968
      HTTPRequest(
2✔
969
        url: configuration.url
2✔
970
          .appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
971
        method: .put,
2✔
972
        query: [URLQueryItem(name: "token", value: token)],
2✔
973
        formData: formData,
2✔
974
        options: options,
2✔
975
        headers: headers
2✔
976
      )
2✔
977
    )
2✔
978
    .decoded(as: UploadResponse.self, decoder: configuration.decoder)
2✔
979
    .Key
2✔
980

2✔
981
    return SignedURLUploadResponse(path: path, fullPath: fullPath)
2✔
982
  }
2✔
983

984
  private func _getFinalPath(_ path: String) -> String {
10✔
985
    "\(bucketId)/\(path)"
10✔
986
  }
10✔
987

988
  private func _removeEmptyFolders(_ path: String) -> String {
4✔
989
    let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
4✔
990
    let cleanedPath = trimmedPath.replacingOccurrences(
4✔
991
      of: "/+", with: "/", options: .regularExpression
4✔
992
    )
4✔
993
    return cleanedPath
4✔
994
  }
4✔
995
}
996

997
extension HTTPField.Name {
998
  static let duplex = Self("duplex")!
999
  static let xUpsert = Self("x-upsert")!
1000
}
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