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

supabase / supabase-swift / 28015504651

23 Jun 2026 09:13AM UTC coverage: 80.889%. First build
28015504651

push

github

web-flow
feat(storage): add TUS resumable uploads, background downloads, and multipart upload engine (#988)

* docs: add storage background transfers design spec

Covers TUS resumable uploads, background URLSession downloads,
StorageTransferTask<Success> public API, error handling, and test plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add storage background transfers implementation plan

9-task TDD plan covering StorageTransferTask types, TUSUploadEngine,
DownloadSessionDelegate, StorageFileAPI wiring, and integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(storage): add StorageTransferTask, TransferEvent, TransferProgress types

Introduces core transfer infrastructure: StorageTransferTask<Success> generic
handle with events stream and async result, TransferEvent and TransferProgress
value types, mapResult transformation, StorageErrorCode/StorageError extensions
for networkError, fileSystemError, and cancelled, and typed aliases
StorageUploadTask and StorageDownloadTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(storage): fix mapResult bridge Task leak, add missing tests

Store the bridge Task in mapResult and cancel it when the returned task
is cancelled to prevent the loop from leaking. Add terminal doc comment
to .completed, and add tests for cancel closure invocation and transform
error propagation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(storage): add backgroundDownloadSessionIdentifier config + download session setup

Adds `backgroundDownloadSessionIdentifier` to `StorageClientConfiguration`,
creates a stub `DownloadSessionDelegate`, and wires a `URLSession` download
session (foreground or background) into `StorageClient` with a
`handleBackgroundEvents(forSessionIdentifier:completionHandler:)` entry point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(storage): add TUSUploadEngine with POST create and PATCH chunk loop

Co... (continued)

982 of 1143 new or added lines in 11 files covered. (85.91%)

8279 of 10235 relevant lines covered (80.89%)

34.2 hits per line

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

94.23
/Sources/Storage/StorageFileAPI.swift
1
import Foundation
2
import Helpers
3
import XCTestDynamicOverlay
4

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

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

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

24
#if DEBUG
25
  import ConcurrencyExtras
26
  let testingBoundary = LockIsolated<String?>(nil)
1✔
27
#endif
28

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

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

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

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

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

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

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

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

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

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

209
  /// Replaces an existing file at the specified path with new `Data`.
210
  ///
211
  /// Sends a `PUT` request to the storage server — the file must already exist.
212
  /// To create a file if it does not exist, use ``upload(_:data:options:method:)`` with
213
  /// ``FileOptions/upsert`` set to `true`.
214
  ///
215
  /// - Parameters:
216
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
217
  ///   - data: The new raw file bytes.
218
  ///   - options: Upload options such as content type and cache duration.
219
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
220
  ///
221
  /// ## Example
222
  ///
223
  /// ```swift
224
  /// let response = try await storage.from("avatars").update(
225
  ///   "user-123/photo.png",
226
  ///   data: newImageData,
227
  ///   options: FileOptions(contentType: "image/png")
228
  /// ).value
229
  /// ```
230
  @discardableResult
231
  public func update(
232
    _ path: String,
233
    data: Data,
234
    options: FileOptions = FileOptions()
235
  ) -> StorageUploadTask {
3✔
236
    MultipartUploadEngine.makeTask(
3✔
237
      bucketId: bucketId, path: path, source: .data(data), options: options,
3✔
238
      httpMethod: .put, client: client)
3✔
239
  }
3✔
240

241
  /// Replaces an existing file at the specified path with the contents of a local `URL`.
242
  ///
243
  /// Sends a `PUT` request to the storage server — the file must already exist.
244
  /// To create a file if it does not exist, use ``upload(_:fileURL:options:method:)`` with
245
  /// ``FileOptions/upsert`` set to `true`.
246
  ///
247
  /// - Parameters:
248
  ///   - path: The path of the file to replace, e.g. `"folder/image.png"`.
249
  ///   - fileURL: A local `file://` URL pointing to the replacement file.
250
  ///   - options: Upload options such as content type and cache duration.
251
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
252
  /// - Returns: A ``StorageUploadTask`` that can be awaited for the result, or observed for progress.
253
  ///
254
  /// ## Example
255
  ///
256
  /// ```swift
257
  /// try await storage.from("documents").update(
258
  ///   "reports/2024/annual.pdf",
259
  ///   fileURL: newFileURL
260
  /// ).value
261
  /// ```
262
  @discardableResult
263
  public func update(
264
    _ path: String,
265
    fileURL: URL,
266
    options: FileOptions = FileOptions()
267
  ) -> StorageUploadTask {
1✔
268
    MultipartUploadEngine.makeTask(
1✔
269
      bucketId: bucketId, path: path, source: .fileURL(fileURL), options: options,
1✔
270
      httpMethod: .put, client: client)
1✔
271
  }
1✔
272

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

3✔
308
    try await client.fetchData(
3✔
309
      .post,
3✔
310
      "object/move",
3✔
311
      body: .data(client.encoder.encode(body))
3✔
312
    )
3✔
313
  }
1✔
314

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

1✔
346
    let body: [String: String?] = [
1✔
347
      "bucketId": bucketId,
1✔
348
      "sourceKey": source,
1✔
349
      "destinationKey": destination,
1✔
350
      "destinationBucket": options?.destinationBucket,
1✔
351
    ]
1✔
352

1✔
353
    let response: UploadResponse = try await client.fetchDecoded(
1✔
354
      .post,
1✔
355
      "object/copy",
1✔
356
      body: .data(client.encoder.encode(body))
1✔
357
    )
1✔
358

1✔
359
    return response.Key
1✔
360
  }
1✔
361

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

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

3✔
416
    return try makeSignedURL(
3✔
417
      response.signedURL,
3✔
418
      download: download,
3✔
419
      cacheNonce: cacheNonce
3✔
420
    )
3✔
421
  }
3✔
422

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

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

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

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

13✔
499
    baseComponents.path +=
13✔
500
      signedURLComponents.path.hasPrefix("/")
13✔
501
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
502
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
503

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

13✔
516
    if let cacheNonce {
13✔
517
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
518
      baseComponents.queryItems!.append(
2✔
519
        URLQueryItem(name: "cacheNonce", value: cacheNonce)
2✔
520
      )
2✔
521
    }
2✔
522

13✔
523
    guard let url = baseComponents.url else {
13✔
524
      throw URLError(.badURL)
×
525
    }
13✔
526
    return url
13✔
527
  }
13✔
528

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

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

1✔
586
    return try await client.fetchDecoded(
1✔
587
      .post,
1✔
588
      "object/list/\(bucketId)",
1✔
589
      body: .data(originalEncoder.encode(options))
1✔
590
    )
1✔
591
  }
1✔
592

593
  /// Downloads a file to a temporary location on disk.
594
  ///
595
  /// The task's success value is a `URL` pointing to a temporary file. Move or copy the file to a
596
  /// permanent location before the app exits — the file is not guaranteed to persist across
597
  /// launches.
598
  ///
599
  /// Downloads support pause and resume. Calling ``StorageDownloadTask/pause()`` suspends the
600
  /// transfer and captures resume data when the server returns `Accept-Ranges: bytes`. Calling
601
  /// ``StorageDownloadTask/resume()`` continues from the last received byte when resume data is
602
  /// available, or restarts from the beginning otherwise.
603
  ///
604
  /// When ``StorageClientConfiguration/backgroundDownloadSessionIdentifier`` is set, downloads
605
  /// continue while the app is suspended. Wire up
606
  /// ``StorageClient/handleBackgroundEvents(forSessionIdentifier:completionHandler:)`` in your
607
  /// `AppDelegate` to support background transfers.
608
  ///
609
  /// - Parameters:
610
  ///   - path: Path within the bucket, e.g. `"folder/image.png"`.
611
  ///   - options: Optional on-the-fly image transformation (resize, reformat, quality).
612
  ///   - query: Additional query items appended to the request URL.
613
  ///   - cacheNonce: An opaque string appended as `cacheNonce=<value>` for cache busting.
614
  /// - Returns: A ``StorageDownloadTask`` whose success value is a `URL` to the file on disk.
615
  ///
616
  /// ## Example
617
  ///
618
  /// ```swift
619
  /// let task = storage.from("videos").download(path: "clip.mp4")
620
  ///
621
  /// // Pause mid-download and resume later
622
  /// await task.pause()
623
  /// await task.resume()
624
  ///
625
  /// // Await the final file URL
626
  /// let url = try await task.value
627
  /// let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
628
  ///   .appendingPathComponent("clip.mp4")
629
  /// try FileManager.default.moveItem(at: url, to: dest)
630
  /// ```
631
  @discardableResult
632
  public func download(
633
    path: String,
634
    options: TransformOptions? = nil,
635
    query additionalQueryItems: [URLQueryItem]? = nil,
636
    cacheNonce: String? = nil
637
  ) -> StorageDownloadTask {
14✔
638
    let url = _downloadURL(
14✔
639
      path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce)
14✔
640
    // Use http.createRequest so the token provider is called before the
14✔
641
    // URLSession download task is created — client.mergedHeaders alone strips
14✔
642
    // the Authorization header when a token provider is configured.
14✔
643
    return client.downloadDelegate.makeStorageDownloadTask(in: client.downloadSession) {
14✔
644
      try await client.http.createRequest(.get, url: url, headers: client.mergedHeaders([:]))
13✔
645
    }
13✔
646
  }
14✔
647

648
  /// Downloads a file into memory as `Data`.
649
  ///
650
  /// The entire file is held in memory, so prefer ``download(path:options:)`` for large files or
651
  /// when you need background transfer support.
652
  ///
653
  /// - Parameters:
654
  ///   - path: Path within the bucket, e.g. `"folder/image.png"`.
655
  ///   - options: Optional on-the-fly image transformation.
656
  ///   - query: Additional query items appended to the request URL.
657
  ///   - cacheNonce: An opaque string appended as `cacheNonce=<value>` for cache busting.
658
  /// - Returns: A task whose success value is the raw file bytes.
659
  ///
660
  /// ## Example
661
  ///
662
  /// ```swift
663
  /// let data = try await storage.from("avatars").downloadData(path: "user-123/photo.png").value
664
  /// let image = UIImage(data: data)
665
  /// ```
666
  @discardableResult
667
  public func downloadData(
668
    path: String,
669
    options: TransformOptions? = nil,
670
    query additionalQueryItems: [URLQueryItem]? = nil,
671
    cacheNonce: String? = nil
672
  ) -> StorageTransferTask<Data> {
2✔
673
    download(path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce)
2✔
674
      .mapResult { url in
2✔
675
        defer { try? FileManager.default.removeItem(at: url) }
1✔
676
        return try Data(contentsOf: url)
1✔
677
      }
1✔
678
  }
2✔
679

680
  private func _downloadURL(
681
    path: String,
682
    options: TransformOptions?,
683
    query additionalQueryItems: [URLQueryItem]? = nil,
684
    cacheNonce: String? = nil
685
  ) -> URL {
14✔
686
    let finalPath = _getFinalPath(_removeEmptyFolders(path))
14✔
687

14✔
688
    var queryItems = options?.queryItems ?? []
14✔
689
    if let additionalQueryItems { queryItems.append(contentsOf: additionalQueryItems) }
14✔
690
    if let cacheNonce { queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce)) }
14✔
691

14✔
692
    let basePath =
14✔
693
      options.map { !$0.isEmpty } == true
14✔
694
      ? "render/image/authenticated/\(finalPath)"
14✔
695
      : "object/\(finalPath)"
14✔
696

14✔
697
    if queryItems.isEmpty {
14✔
698
      return client.url.appendingPathComponent(basePath)
13✔
699
    }
13✔
700

1✔
701
    var components = URLComponents(
1✔
702
      url: client.url.appendingPathComponent(basePath),
1✔
703
      resolvingAgainstBaseURL: false
1✔
704
    )
1✔
705
    components?.queryItems = queryItems
1✔
706
    return components?.url ?? client.url.appendingPathComponent(basePath)
1✔
707
  }
14✔
708

709
  /// Retrieves extended metadata for a file without downloading its contents.
710
  ///
711
  /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other
712
  /// server-side metadata. To check only whether a file exists, prefer ``exists(path:)``.
713
  ///
714
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
715
  /// - Returns: A ``FileInfo`` containing the file's metadata.
716
  /// - Throws: ``StorageError`` if the file does not exist or the request fails.
717
  ///
718
  /// ## Example
719
  ///
720
  /// ```swift
721
  /// let info = try await storage.from("avatars").info(path: "user-123/photo.png")
722
  /// print("Size: \(info.size ?? 0) bytes, type: \(info.contentType ?? "unknown")")
723
  /// ```
724
  public func info(path: String) async throws -> FileInfo {
1✔
725
    let _path = _getFinalPath(path)
1✔
726

1✔
727
    return try await client.fetchDecoded(.get, "object/info/\(_path)")
1✔
728
  }
1✔
729

730
  /// Checks whether a file exists in the bucket.
731
  ///
732
  /// Issues a `HEAD` request and returns `true` if the server responds with a success status,
733
  /// `false` if the server returns 400 or 404. Any other error is re-thrown.
734
  ///
735
  /// - Parameter path: The path of the file within the bucket, e.g. `"folder/image.png"`.
736
  /// - Returns: `true` if the file exists, `false` if it does not.
737
  /// - Throws: ``StorageError`` for server errors other than 400/404.
738
  ///
739
  /// ## Example
740
  ///
741
  /// ```swift
742
  /// if try await storage.from("avatars").exists(path: "user-123/photo.png") {
743
  ///   print("File found")
744
  /// } else {
745
  ///   print("File not found")
746
  /// }
747
  /// ```
748
  public func exists(path: String) async throws -> Bool {
3✔
749
    do {
3✔
750
      try await client.fetchData(.head, "object/\(bucketId)/\(path)")
3✔
751
      return true
1✔
752
    } catch let error as StorageError
3✔
753
      where error.isNotFound || error.statusCode == 400
3✔
754
    {
3✔
755
      // The Storage server returns 400 (instead of 404) for HEAD requests on non-existent objects.
2✔
756
      return false
2✔
757
    }
2✔
758
  }
3✔
759

760
  /// Returns the public URL for a file in a public bucket.
761
  ///
762
  /// The bucket must be configured as public; for private buckets use
763
  /// ``createSignedURL(path:expiresIn:download:transform:cacheNonce:)`` instead.
764
  ///
765
  /// - Parameters:
766
  ///   - path: The path of the file within the bucket, e.g. `"user-123/avatar.png"`.
767
  ///   - download: When non-`nil`, the browser treats the URL as a file download.
768
  ///   - options: Optional on-the-fly image transformation (resize, reformat, quality).
769
  ///   - cacheNonce: An opaque string appended as a `cacheNonce` query parameter.
770
  /// - Returns: The public `URL` for the file.
771
  /// - Throws: `URLError(.badURL)` if the resulting URL cannot be constructed.
772
  ///
773
  /// ## Example
774
  ///
775
  /// ```swift
776
  /// // Direct embed URL
777
  /// let url = try storage.from("avatars").getPublicURL(path: "user-123/photo.png")
778
  ///
779
  /// // 200×200 thumbnail in WebP
780
  /// let thumbURL = try storage.from("avatars").getPublicURL(
781
  ///   path: "user-123/photo.png",
782
  ///   options: TransformOptions(width: 200, height: 200, format: .webp)
783
  /// )
784
  /// ```
785
  public func getPublicURL(
786
    path: String,
787
    download: DownloadBehavior? = nil,
788
    options: TransformOptions? = nil,
789
    cacheNonce: String? = nil
790
  ) throws -> URL {
7✔
791
    var queryItems: [URLQueryItem] = []
7✔
792

7✔
793
    guard
7✔
794
      var components = URLComponents(
7✔
795
        url: client.url,
7✔
796
        resolvingAgainstBaseURL: true
7✔
797
      )
7✔
798
    else {
7✔
799
      throw URLError(.badURL)
×
800
    }
7✔
801

7✔
802
    if let download {
7✔
803
      let value: String
3✔
804
      switch download {
3✔
805
      case .withOriginalName: value = ""
3✔
806
      case .named(let name): value = name
3✔
807
      }
3✔
808
      queryItems.append(URLQueryItem(name: "download", value: value))
3✔
809
    }
7✔
810

7✔
811
    if let optionsQueryItems = options?.queryItems {
7✔
812
      queryItems.append(contentsOf: optionsQueryItems)
3✔
813
    }
3✔
814

7✔
815
    if let cacheNonce {
7✔
816
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
817
    }
1✔
818

7✔
819
    let renderPath =
7✔
820
      options.map { !$0.isEmpty } == true ? "render/image" : "object"
7✔
821
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
7✔
822
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
7✔
823

7✔
824
    guard let generatedUrl = components.url else {
7✔
825
      throw URLError(.badURL)
×
826
    }
7✔
827
    return generatedUrl
7✔
828
  }
7✔
829

830
  /// Creates a signed upload URL for uploading a file without further authentication.
831
  ///
832
  /// Signed upload URLs are valid for **2 hours** and allow anyone in possession of the URL to
833
  /// upload a file to the specified path. This is useful for client-side uploads where you do not
834
  /// want to expose Storage credentials.
835
  ///
836
  /// After calling this method, pass the returned ``SignedUploadURL/token`` to
837
  /// ``uploadToSignedURL(_:token:data:options:)`` or
838
  /// ``uploadToSignedURL(_:token:fileURL:options:)`` to perform the actual upload.
839
  ///
840
  /// - Parameters:
841
  ///   - path: The destination path within the bucket, e.g. `"user-123/upload.png"`.
842
  ///   - options: Optional overrides, such as enabling upsert behaviour.
843
  /// - Returns: A ``SignedUploadURL`` containing the signed URL, path, and extracted token.
844
  /// - Throws: ``StorageError`` if the request fails.
845
  ///
846
  /// ## Example
847
  ///
848
  /// ```swift
849
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/photo.png")
850
  ///
851
  /// // Later, upload the file using the signed token
852
  /// try await storage.from("uploads").uploadToSignedURL(
853
  ///   signed.path,
854
  ///   token: signed.token,
855
  ///   data: imageData,
856
  ///   options: FileOptions(contentType: "image/png")
857
  /// )
858
  /// ```
859
  public func createSignedUploadURL(
860
    path: String,
861
    options: CreateSignedUploadURLOptions? = nil
862
  ) async throws -> SignedUploadURL {
2✔
863
    struct Response: Decodable {
2✔
864
      let url: String
2✔
865
    }
2✔
866

2✔
867
    var headers = [String: String]()
2✔
868
    if let upsert = options?.upsert, upsert {
2✔
869
      headers[Header.xUpsert] = "true"
1✔
870
    }
1✔
871

2✔
872
    let response: Response = try await client.fetchDecoded(
2✔
873
      .post,
2✔
874
      "object/upload/sign/\(bucketId)/\(path)",
2✔
875
      headers: headers
2✔
876
    )
2✔
877

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

2✔
880
    guard
2✔
881
      let components = URLComponents(
2✔
882
        url: signedURL,
2✔
883
        resolvingAgainstBaseURL: false
2✔
884
      )
2✔
885
    else {
2✔
886
      throw URLError(.badURL)
×
887
    }
2✔
888

2✔
889
    guard
2✔
890
      let token = components.queryItems?.first(where: { $0.name == "token" })?
2✔
891
        .value
2✔
892
    else {
2✔
893
      throw StorageError.noTokenReturned
×
894
    }
2✔
895

2✔
896
    guard let url = components.url else {
2✔
897
      throw URLError(.badURL)
×
898
    }
2✔
899

2✔
900
    return SignedUploadURL(
2✔
901
      signedURL: url,
2✔
902
      path: path,
2✔
903
      token: token
2✔
904
    )
2✔
905
  }
2✔
906

907
  /// Uploads `Data` to a path using a token obtained from ``createSignedUploadURL(path:options:)``.
908
  ///
909
  /// The token must match the path and must not have expired (signed upload URLs are valid for
910
  /// 2 hours).
911
  ///
912
  /// - Parameters:
913
  ///   - path: The destination path within the bucket, matching the path used when the token
914
  ///     was created.
915
  ///   - token: The upload token from ``SignedUploadURL/token``.
916
  ///   - data: The raw file bytes to store.
917
  ///   - options: Upload options such as content type and cache duration.
918
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
919
  ///   result, or observed for progress.
920
  ///
921
  /// ## Example
922
  ///
923
  /// ```swift
924
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "file.pdf")
925
  /// let response = try await storage.from("uploads").uploadToSignedURL(
926
  ///   signed.path,
927
  ///   token: signed.token,
928
  ///   data: pdfData,
929
  ///   options: FileOptions(contentType: "application/pdf")
930
  /// ).value
931
  /// print(response.fullPath)
932
  /// ```
933
  @discardableResult
934
  public func uploadToSignedURL(
935
    _ path: String,
936
    token: String,
937
    data: Data,
938
    options: FileOptions = FileOptions()
939
  ) -> StorageTransferTask<SignedURLUploadResponse> {
1✔
940
    makeSignedURLUploadTask {
1✔
941
      try await self._uploadToSignedURL(
1✔
942
        path: path,
1✔
943
        token: token,
1✔
944
        file: .data(data),
1✔
945
        options: options
1✔
946
      )
1✔
947
    }
1✔
948
  }
1✔
949

950
  /// Uploads a file from a local `URL` to a path using a token obtained from
951
  /// ``createSignedUploadURL(path:options:)``.
952
  ///
953
  /// For files ≥ 10 MB the SDK automatically streams from a temporary file on disk to avoid
954
  /// loading the entire payload into memory.
955
  ///
956
  /// - Parameters:
957
  ///   - path: The destination path within the bucket, matching the path used when the token
958
  ///     was created.
959
  ///   - token: The upload token from ``SignedUploadURL/token``.
960
  ///   - fileURL: A local `file://` URL pointing to the file to upload.
961
  ///   - options: Upload options such as content type and cache duration.
962
  ///     When `contentType` is `nil`, the MIME type is inferred from the file extension.
963
  /// - Returns: A ``StorageTransferTask`` that can be awaited for the ``SignedURLUploadResponse``
964
  ///   result, or observed for progress.
965
  ///
966
  /// ## Example
967
  ///
968
  /// ```swift
969
  /// let fileURL = URL(fileURLWithPath: "/tmp/video.mp4")
970
  /// let signed = try await storage.from("uploads").createSignedUploadURL(path: "user-123/video.mp4")
971
  /// let response = try await storage.from("uploads").uploadToSignedURL(
972
  ///   signed.path,
973
  ///   token: signed.token,
974
  ///   fileURL: fileURL
975
  /// ).value
976
  /// ```
977
  @discardableResult
978
  public func uploadToSignedURL(
979
    _ path: String,
980
    token: String,
981
    fileURL: URL,
982
    options: FileOptions = FileOptions()
983
  ) -> StorageTransferTask<SignedURLUploadResponse> {
2✔
984
    makeSignedURLUploadTask {
2✔
985
      try await self._uploadToSignedURL(
2✔
986
        path: path,
2✔
987
        token: token,
2✔
988
        file: .fileURL(fileURL),
2✔
989
        options: options
2✔
990
      )
2✔
991
    }
2✔
992
  }
2✔
993

994
  private func makeSignedURLUploadTask(
995
    _ body: @Sendable @escaping () async throws -> SignedURLUploadResponse
996
  ) -> StorageTransferTask<SignedURLUploadResponse> {
3✔
997
    let (eventStream, eventsContinuation) =
3✔
998
      AsyncStream<TransferEvent<SignedURLUploadResponse>>.makeStream()
3✔
999
    let (resultStream, resultContinuation) =
3✔
1000
      AsyncStream<Result<SignedURLUploadResponse, any Error>>.makeStream(
3✔
1001
        bufferingPolicy: .bufferingNewest(1))
3✔
1002

3✔
1003
    let resultTask = Task<SignedURLUploadResponse, any Error> {
3✔
1004
      for await r in resultStream { return try r.get() }
3✔
NEW
1005
      throw StorageError.cancelled
×
1006
    }
3✔
1007

3✔
1008
    let uploadTask = Task {
3✔
1009
      do {
3✔
1010
        let response = try await body()
3✔
1011
        eventsContinuation.yield(.completed(response))
3✔
1012
        eventsContinuation.finish()
3✔
1013
        resultContinuation.yield(.success(response))
3✔
1014
        resultContinuation.finish()
3✔
1015
      } catch {
3✔
NEW
1016
        let storageError = StorageError.from(error)
×
NEW
1017
        eventsContinuation.yield(.failed(storageError))
×
NEW
1018
        eventsContinuation.finish()
×
NEW
1019
        resultContinuation.yield(.failure(storageError))
×
NEW
1020
        resultContinuation.finish()
×
1021
      }
3✔
1022
    }
3✔
1023

3✔
1024
    return StorageTransferTask<SignedURLUploadResponse>(
3✔
1025
      events: eventStream,
3✔
1026
      resultTask: resultTask,
3✔
1027
      pause: {},
3✔
1028
      resume: {},
3✔
1029
      cancel: { uploadTask.cancel() }
3✔
1030
    )
3✔
1031
  }
3✔
1032

1033
  private func _uploadToSignedURL(
1034
    path: String,
1035
    token: String,
1036
    file: UploadSource,
1037
    options: FileOptions
1038
  ) async throws -> SignedURLUploadResponse {
3✔
1039
    var headers = multipartHeaders(options: options)
3✔
1040
    headers[Header.xUpsert] = "\(options.upsert)"
3✔
1041

3✔
1042
    let response: SignedUploadResponse = try await _performMultipartRequest(
3✔
1043
      .put,
3✔
1044
      url: storageURL(
3✔
1045
        path: "object/upload/sign/\(bucketId)/\(path)",
3✔
1046
        queryItems: [URLQueryItem(name: "token", value: token)]
3✔
1047
      ),
3✔
1048
      path: path,
3✔
1049
      file: file,
3✔
1050
      options: options,
3✔
1051
      headers: headers
3✔
1052
    )
3✔
1053

3✔
1054
    return SignedURLUploadResponse(path: path, fullPath: response.Key)
3✔
1055
  }
3✔
1056

1057
  private func _performMultipartRequest<Response: Decodable>(
1058
    _ method: HTTPMethod,
1059
    url: URL,
1060
    path: String,
1061
    file: UploadSource,
1062
    options: FileOptions,
1063
    headers: [String: String]
1064
  ) async throws -> Response {
3✔
1065
    #if DEBUG
1066
      let builder = MultipartBuilder(
3✔
1067
        boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)"
3✔
1068
      )
3✔
1069
    #else
1070
      let builder = MultipartBuilder()
1071
    #endif
1072

3✔
1073
    let multipart = file.append(
3✔
1074
      to: builder,
3✔
1075
      withPath: path,
3✔
1076
      options: options
3✔
1077
    )
3✔
1078

3✔
1079
    var headers = headers
3✔
1080
    headers[Header.contentType] = multipart.contentType
3✔
1081

3✔
1082
    let request = try await client.http.createRequest(
3✔
1083
      method,
3✔
1084
      url: url,
3✔
1085
      headers: client.mergedHeaders(headers)
3✔
1086
    )
3✔
1087

3✔
1088
    do {
3✔
1089
      client.logRequest(method, url: url)
3✔
1090
      let data: Data
3✔
1091
      let response: URLResponse
3✔
1092

3✔
1093
      if file.usesTempFileUpload {
3✔
1094
        let tempFile = try multipart.buildToTempFile()
×
1095
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
1096

×
1097
        (data, response) = try await client.http.session.upload(
×
1098
          for: request,
×
NEW
1099
          fromFile: tempFile
×
1100
        )
×
1101
      } else {
3✔
1102
        (data, response) = try await client.http.session.upload(
3✔
1103
          for: request,
3✔
1104
          from: try multipart.buildInMemory()
3✔
1105
        )
3✔
1106
      }
3✔
1107

3✔
1108
      let httpResponse = try client.http.validateResponse(response, data: data)
3✔
1109
      client.logResponse(httpResponse, data: data)
3✔
1110
      return try client.decoder.decode(Response.self, from: data)
3✔
1111
    } catch {
3✔
1112
      client.logFailure(error)
×
1113
      throw client.translateStorageError(error)
×
1114
    }
×
1115
  }
3✔
1116

1117
  private func multipartHeaders(options: FileOptions) -> [String: String] {
3✔
1118
    var headers: [String: String] = [:]
3✔
1119
    headers.setIfMissing(
3✔
1120
      Header.cacheControl,
3✔
1121
      value: "max-age=\(options.cacheControl)"
3✔
1122
    )
3✔
1123
    return headers
3✔
1124
  }
3✔
1125

1126
  private func storageURL(path: String, queryItems: [URLQueryItem] = []) throws
1127
    -> URL
1128
  {
3✔
1129
    var components = URLComponents(
3✔
1130
      url: client.url.appendingPathComponent(path),
3✔
1131
      resolvingAgainstBaseURL: false
3✔
1132
    )
3✔
1133
    components?.queryItems = queryItems.isEmpty ? nil : queryItems
3✔
1134

3✔
1135
    guard let url = components?.url else {
3✔
1136
      throw URLError(.badURL)
×
1137
    }
3✔
1138

3✔
1139
    return url
3✔
1140
  }
3✔
1141

1142
  private func _getFinalPath(_ path: String) -> String {
15✔
1143
    let trimmed = path.hasPrefix("/") ? String(path.dropFirst()) : path
15✔
1144
    return "\(bucketId)/\(trimmed)"
15✔
1145
  }
15✔
1146
}
1147

1148
func _removeEmptyFolders(_ path: String) -> String {
14✔
1149
  let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
14✔
1150
  let cleanedPath = trimmedPath.replacingOccurrences(
14✔
1151
    of: "/+",
14✔
1152
    with: "/",
14✔
1153
    options: .regularExpression
14✔
1154
  )
14✔
1155
  return cleanedPath
14✔
1156
}
14✔
1157

1158
extension [String: String] {
1159
  fileprivate mutating func setIfMissing(_ key: String, value: String) {
3✔
1160
    guard
3✔
1161
      keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame })
3✔
1162
        == nil
3✔
1163
    else {
3✔
1164
      return
×
1165
    }
3✔
1166

3✔
1167
    self[key] = value
3✔
1168
  }
3✔
1169
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc