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

supabase / supabase-swift / 28167379888

25 Jun 2026 11:38AM UTC coverage: 80.743%. First build
28167379888

Pull #987

github

web-flow
Merge 693c0012d into c08ceb649
Pull Request #987: feat(storage)!: storage v3

1784 of 2046 new or added lines in 14 files covered. (87.19%)

8264 of 10235 relevant lines covered (80.74%)

34.21 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✔
NEW
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✔
NEW
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([:]))
14✔
645
    }
14✔
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✔
NEW
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✔
NEW
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✔
NEW
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✔
NEW
893
      throw StorageError.noTokenReturned
×
894
    }
2✔
895

2✔
896
    guard let url = components.url else {
2✔
NEW
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✔
NEW
1094
        let tempFile = try multipart.buildToTempFile()
×
NEW
1095
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
NEW
1096

×
NEW
1097
        (data, response) = try await client.http.session.upload(
×
NEW
1098
          for: request,
×
NEW
1099
          fromFile: tempFile
×
NEW
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✔
NEW
1112
      client.logFailure(error)
×
NEW
1113
      throw client.translateStorageError(error)
×
NEW
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✔
NEW
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✔
NEW
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