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

supabase / supabase-swift / 25106346352

29 Apr 2026 11:31AM UTC coverage: 80.475% (-0.4%) from 80.858%
25106346352

Pull #946

github

web-flow
Merge 8aa55b2df into 9e0637908
Pull Request #946: feat(storage): add multipart upload support [wip]

612 of 696 new or added lines in 4 files covered. (87.93%)

4 existing lines in 1 file now uncovered.

7114 of 8840 relevant lines covered (80.48%)

30.28 hits per line

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

91.79
/Sources/Storage/StorageFileApi.swift
1
import Foundation
2
import Helpers
3

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

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

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

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

27
  func append(
28
    to builder: MultipartBuilder,
29
    withPath path: String,
30
    options: FileOptions
31
  ) -> MultipartBuilder {
7✔
32
    var builder = builder.addText(name: "cacheControl", value: options.cacheControl)
7✔
33

7✔
34
    if let metadata = options.metadata {
7✔
35
      builder = builder.addText(
4✔
36
        name: "metadata",
4✔
37
        value: String(data: encodeMetadata(metadata), encoding: .utf8) ?? ""
4✔
38
      )
4✔
39
    }
4✔
40

7✔
41
    switch self {
7✔
42
    case .data(let data):
7✔
43
      return builder.addData(
3✔
44
        name: "",
3✔
45
        data: data,
3✔
46
        fileName: path.fileName,
3✔
47
        mimeType: options.contentType
3✔
48
          ?? mimeType(forPathExtension: path.pathExtension)
3✔
49
      )
3✔
50

7✔
51
    case .url(let url):
7✔
52
      return builder.addFile(
4✔
53
        name: "",
4✔
54
        fileURL: url,
4✔
55
        fileName: url.lastPathComponent,
4✔
56
        mimeType: options.contentType
4✔
57
          ?? mimeType(forPathExtension: url.pathExtension)
4✔
58
      )
4✔
59
    }
7✔
60
  }
7✔
61

62
  var usesTempFileUpload: Bool {
63
    get throws {
7✔
64
      guard case .url(let url) = self else { return false }
7✔
65

4✔
66
      let fileSize = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
4✔
67
      return fileSize >= 10 * 1024 * 1024
4✔
68
    }
7✔
69
  }
70

71
  func defaultOptions() -> FileOptions {
2✔
72
    switch self {
2✔
73
    case .data:
2✔
74
      return defaultFileOptions
1✔
75

2✔
76
    case .url:
2✔
77
      var options = defaultFileOptions
1✔
78
      options.contentType = nil
1✔
79
      return options
1✔
80
    }
2✔
81
  }
2✔
82
}
83

84
#if DEBUG
85
  import ConcurrencyExtras
86
  let testingBoundary = LockIsolated<String?>(nil)
1✔
87
#endif
88

89
/// Supabase Storage File API for file operations within a bucket.
90
///
91
/// - Note: Thread Safety: Inherits immutable design from `StorageApi`. The additional `bucketId`
92
///   property is also immutable (`let`).
93
public struct StorageFileAPI {
94
  /// The bucket id to operate on.
95
  let bucketId: String
96
  let client: StorageClient
97

98
  init(bucketId: String, client: StorageClient) {
39✔
99
    self.bucketId = bucketId
39✔
100
    self.client = client
39✔
101
  }
39✔
102

103
  private struct MoveResponse: Decodable {
104
    let message: String
105
  }
106

107
  private struct SignedURLAPIResponse: Decodable {
108
    let signedURL: String
109
  }
110

111
  private struct SignedURLsAPIResponse: Decodable {
112
    let signedURL: String?
113
    let path: String
114
    let error: String?
115
  }
116

117
  private enum Header {
118
    static let cacheControl = "Cache-Control"
119
    static let contentType = "Content-Type"
120
    static let duplex = "duplex"
121
    static let xUpsert = "x-upsert"
122
  }
123

124
  private struct UploadResponse: Decodable {
125
    let Key: String
126
    let Id: String
127
  }
128

129
  private struct SignedUploadResponse: Decodable {
130
    let Key: String
131
  }
132

133
  private func _uploadOrUpdate(
134
    method: HTTPMethod,
135
    path: String,
136
    file: FileUpload,
137
    options: FileOptions?
138
  ) async throws -> FileUploadResponse {
4✔
139
    let options = options ?? defaultFileOptions
4✔
140
    let cleanPath = _removeEmptyFolders(path)
4✔
141
    let _path = _getFinalPath(cleanPath)
4✔
142

4✔
143
    var headers = multipartHeaders(options: options)
4✔
144
    if method == .post {
4✔
145
      headers[Header.xUpsert] = "\(options.upsert)"
2✔
146
    }
2✔
147

4✔
148
    let response: UploadResponse = try await uploadMultipart(
4✔
149
      method,
4✔
150
      url: client.configuration.url.appendingPathComponent("object/\(_path)"),
4✔
151
      path: path,
4✔
152
      file: file,
4✔
153
      options: options,
4✔
154
      headers: headers
4✔
155
    )
4✔
156

4✔
157
    return FileUploadResponse(
4✔
158
      id: response.Id,
4✔
159
      path: path,
4✔
160
      fullPath: response.Key
4✔
161
    )
4✔
162
  }
4✔
163

164
  @available(macOS 10.15.4, *)
165
  @discardableResult
166
  public func uploadFile(
167
    _ fileURL: URL,
168
    to path: String,
169
    options: FileOptions? = nil
NEW
170
  ) async throws -> FileUploadResponse {
×
NEW
171
    try await upload(
×
NEW
172
      path, fileURL: fileURL, options: options ?? FileUpload.url(fileURL).defaultOptions())
×
NEW
173
  }
×
174

175
  /// Uploads a file to an existing bucket.
176
  /// - Parameters:
177
  ///   - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
178
  ///   - data: The Data to be stored in the bucket.
179
  ///   - options: The options for the uploaded file.
180
  @discardableResult
181
  public func upload(
182
    _ path: String,
183
    data: Data,
184
    options: FileOptions = FileOptions()
185
  ) async throws -> FileUploadResponse {
1✔
186
    try await _uploadOrUpdate(
1✔
187
      method: .post,
1✔
188
      path: path,
1✔
189
      file: .data(data),
1✔
190
      options: options
1✔
191
    )
1✔
192
  }
1✔
193

194
  /// Uploads a file to an existing bucket.
195
  /// - Parameters:
196
  ///   - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
197
  ///   - fileURL: The file URL to be stored in the bucket.
198
  ///   - options: The options for the uploaded file.
199
  @discardableResult
200
  public func upload(
201
    _ path: String,
202
    fileURL: URL,
203
    options: FileOptions = FileOptions()
204
  ) async throws -> FileUploadResponse {
1✔
205
    try await _uploadOrUpdate(
1✔
206
      method: .post,
1✔
207
      path: path,
1✔
208
      file: .url(fileURL),
1✔
209
      options: options
1✔
210
    )
1✔
211
  }
1✔
212

213
  /// Replaces an existing file at the specified path with a new one.
214
  /// - Parameters:
215
  ///   - path: The relative file path. Should be of the format `folder/subfolder`. The bucket already exist before attempting to upload.
216
  ///   - data: The Data to be stored in the bucket.
217
  ///   - options: The options for the updated file.
218
  @discardableResult
219
  public func update(
220
    _ path: String,
221
    data: Data,
222
    options: FileOptions = FileOptions()
223
  ) async throws -> FileUploadResponse {
1✔
224
    try await _uploadOrUpdate(
1✔
225
      method: .put,
1✔
226
      path: path,
1✔
227
      file: .data(data),
1✔
228
      options: options
1✔
229
    )
1✔
230
  }
1✔
231

232
  /// Replaces an existing file at the specified path with a new one.
233
  /// - Parameters:
234
  ///   - path: The relative file path. Should be of the format `folder/subfolder`. The bucket already exist before attempting to upload.
235
  ///   - fileURL: The file URL to be stored in the bucket.
236
  ///   - options: The options for the updated file.
237
  @discardableResult
238
  public func update(
239
    _ path: String,
240
    fileURL: URL,
241
    options: FileOptions = FileOptions()
242
  ) async throws -> FileUploadResponse {
1✔
243
    try await _uploadOrUpdate(
1✔
244
      method: .put,
1✔
245
      path: path,
1✔
246
      file: .url(fileURL),
1✔
247
      options: options
1✔
248
    )
1✔
249
  }
1✔
250

251
  /// Moves an existing file to a new path.
252
  /// - Parameters:
253
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
254
  ///   - destination: The new file path, including the new file name. For example `folder/image-new.png`.
255
  ///   - options: The destination options.
256
  public func move(
257
    from source: String,
258
    to destination: String,
259
    options: DestinationOptions? = nil
260
  ) async throws {
3✔
261
    let body: [String: String?] = [
3✔
262
      "bucketId": bucketId,
3✔
263
      "sourceKey": source,
3✔
264
      "destinationKey": destination,
3✔
265
      "destinationBucket": options?.destinationBucket,
3✔
266
    ]
3✔
267

3✔
268
    try await client.fetchData(
3✔
269
      .post,
3✔
270
      "object/move",
3✔
271
      body: .data(client.configuration.encoder.encode(body))
3✔
272
    )
3✔
273
  }
1✔
274

275
  /// Copies an existing file to a new path.
276
  /// - Parameters:
277
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
278
  ///   - destination: The new file path, including the new file name. For example `folder/image-copy.png`.
279
  ///   - options: The destination options.
280
  @discardableResult
281
  public func copy(
282
    from source: String,
283
    to destination: String,
284
    options: DestinationOptions? = nil
285
  ) async throws -> String {
1✔
286
    struct UploadResponse: Decodable {
1✔
287
      let Key: String
1✔
288
    }
1✔
289

1✔
290
    let body: [String: String?] = [
1✔
291
      "bucketId": bucketId,
1✔
292
      "sourceKey": source,
1✔
293
      "destinationKey": destination,
1✔
294
      "destinationBucket": options?.destinationBucket,
1✔
295
    ]
1✔
296

1✔
297
    let response: UploadResponse = try await client.fetchDecoded(
1✔
298
      .post,
1✔
299
      "object/copy",
1✔
300
      body: .data(client.configuration.encoder.encode(body))
1✔
301
    )
1✔
302

1✔
303
    return response.Key
1✔
304
  }
1✔
305

306
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
307
  /// - Parameters:
308
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
309
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
310
  ///   - download: Trigger a download with the specified file name.
311
  ///   - transform: Transform the asset before serving it to the client.
312
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
313
  public func createSignedURL(
314
    path: String,
315
    expiresIn: Int,
316
    download: String? = nil,
317
    transform: TransformOptions? = nil,
318
    cacheNonce: String? = nil
319
  ) async throws -> URL {
3✔
320
    struct Body: Encodable {
3✔
321
      let expiresIn: Int
3✔
322
      let transform: TransformOptions?
3✔
323
    }
3✔
324

3✔
325
    let encoder = JSONEncoder.unconfiguredEncoder
3✔
326

3✔
327
    let response: SignedURLAPIResponse = try await client.fetchDecoded(
3✔
328
      .post,
3✔
329
      "object/sign/\(bucketId)/\(path)",
3✔
330
      body: .data(
3✔
331
        encoder.encode(
3✔
332
          Body(expiresIn: expiresIn, transform: transform)
3✔
333
        )
3✔
334
      )
3✔
335
    )
3✔
336

3✔
337
    return try makeSignedURL(
3✔
338
      response.signedURL,
3✔
339
      download: download,
3✔
340
      cacheNonce: cacheNonce
3✔
341
    )
3✔
342
  }
3✔
343

344
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
345
  /// - Parameters:
346
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
347
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
348
  ///   - download: Trigger a download with the default file name.
349
  ///   - transform: Transform the asset before serving it to the client.
350
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
351
  public func createSignedURL(
352
    path: String,
353
    expiresIn: Int,
354
    download: Bool,
355
    transform: TransformOptions? = nil,
356
    cacheNonce: String? = nil
357
  ) async throws -> URL {
1✔
358
    try await createSignedURL(
1✔
359
      path: path,
1✔
360
      expiresIn: expiresIn,
1✔
361
      download: download ? "" : nil,
1✔
362
      transform: transform,
1✔
363
      cacheNonce: cacheNonce
1✔
364
    )
1✔
365
  }
1✔
366

367
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
368
  ///
369
  /// Each item in the returned array is a ``SignedURLResult``: either `.success(path:signedURL:)` or
370
  /// `.failure(path:error:)`. Exactly one case is guaranteed per item.
371
  /// - Parameters:
372
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
373
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
374
  ///   - download: Trigger a download with the specified file name.
375
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
376
  public func createSignedURLs(
377
    paths: [String],
378
    expiresIn: Int,
379
    download: String? = nil,
380
    cacheNonce: String? = nil
381
  ) async throws -> [SignedURLResult] {
5✔
382
    struct Params: Encodable {
5✔
383
      let expiresIn: Int
5✔
384
      let paths: [String]
5✔
385
    }
5✔
386

5✔
387
    let encoder = JSONEncoder.unconfiguredEncoder
5✔
388

5✔
389
    let response: [SignedURLsAPIResponse] = try await client.fetchDecoded(
5✔
390
      .post,
5✔
391
      "object/sign/\(bucketId)",
5✔
392
      body: .data(
5✔
393
        encoder.encode(
5✔
394
          Params(expiresIn: expiresIn, paths: paths)
5✔
395
        )
5✔
396
      )
5✔
397
    )
5✔
398

5✔
399
    return try response.map { item in
9✔
400
      if let signedURLString = item.signedURL {
9✔
401
        let url = try makeSignedURL(
8✔
402
          signedURLString,
8✔
403
          download: download,
8✔
404
          cacheNonce: cacheNonce
8✔
405
        )
8✔
406
        return .success(path: item.path, signedURL: url)
8✔
407
      } else {
8✔
408
        return .failure(path: item.path, error: item.error ?? "Unknown error")
1✔
409
      }
1✔
410
    }
9✔
411
  }
5✔
412

413
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
414
  ///
415
  /// Each item in the returned array is a ``SignedURLResult``: either `.success(path:signedURL:)` or
416
  /// `.failure(path:error:)`. Exactly one case is guaranteed per item.
417
  /// - Parameters:
418
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
419
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
420
  ///   - download: Trigger a download with the default file name.
421
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
422
  public func createSignedURLs(
423
    paths: [String],
424
    expiresIn: Int,
425
    download: Bool,
426
    cacheNonce: String? = nil
427
  ) async throws -> [SignedURLResult] {
1✔
428
    try await createSignedURLs(
1✔
429
      paths: paths,
1✔
430
      expiresIn: expiresIn,
1✔
431
      download: download ? "" : nil,
1✔
432
      cacheNonce: cacheNonce
1✔
433
    )
1✔
434
  }
1✔
435

436
  private func makeSignedURL(
437
    _ signedURL: String,
438
    download: String?,
439
    cacheNonce: String? = nil
440
  )
441
    throws -> URL
442
  {
13✔
443
    guard let signedURLComponents = URLComponents(string: signedURL),
13✔
444
      var baseComponents = URLComponents(
13✔
445
        url: client.configuration.url,
13✔
446
        resolvingAgainstBaseURL: false
13✔
447
      )
13✔
448
    else {
13✔
449
      throw URLError(.badURL)
×
450
    }
13✔
451

13✔
452
    baseComponents.path +=
13✔
453
      signedURLComponents.path.hasPrefix("/")
13✔
454
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
13✔
455
    baseComponents.queryItems = signedURLComponents.queryItems
13✔
456

13✔
457
    if let download {
13✔
458
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
459
      baseComponents.queryItems!.append(
3✔
460
        URLQueryItem(name: "download", value: download)
3✔
461
      )
3✔
462
    }
3✔
463

13✔
464
    if let cacheNonce {
13✔
465
      baseComponents.queryItems = baseComponents.queryItems ?? []
2✔
466
      baseComponents.queryItems!.append(
2✔
467
        URLQueryItem(name: "cacheNonce", value: cacheNonce)
2✔
468
      )
2✔
469
    }
2✔
470

13✔
471
    guard let signedURL = baseComponents.url else {
13✔
472
      throw URLError(.badURL)
×
473
    }
13✔
474

13✔
475
    return signedURL
13✔
476
  }
13✔
477

478
  /// Deletes files within the same bucket
479
  /// - Parameters:
480
  ///   - paths: An array of files to be deletes, including the path and file name. For example [`folder/image.png`].
481
  /// - Returns: A list of removed ``FileObject``.
482
  @discardableResult
483
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
484
    try await client.fetchDecoded(
1✔
485
      .delete,
1✔
486
      "object/\(bucketId)",
1✔
487
      body: .data(client.configuration.encoder.encode(["prefixes": paths]))
1✔
488
    )
1✔
489
  }
1✔
490

491
  /// Lists all the files within a bucket.
492
  /// - Parameters:
493
  ///   - path: The folder path.
494
  ///   - options: Search options, including `limit`, `offset`, and `sortBy`.
495
  public func list(
496
    path: String? = nil,
497
    options: SearchOptions? = nil
498
  ) async throws -> [FileObject] {
1✔
499
    let encoder = JSONEncoder.unconfiguredEncoder
1✔
500

1✔
501
    var options = options ?? defaultSearchOptions
1✔
502
    options.prefix = path ?? ""
1✔
503

1✔
504
    return try await client.fetchDecoded(
1✔
505
      .post,
1✔
506
      "object/list/\(bucketId)",
1✔
507
      body: .data(encoder.encode(options))
1✔
508
    )
1✔
509
  }
1✔
510

511
  /// Downloads a file from a private bucket. For public buckets, make a request to the URL returned
512
  /// from ``StorageFileApi/getPublicURL(path:download:fileName:options:)`` instead.
513
  /// - Parameters:
514
  ///   - path: The file path to be downloaded, including the path and file name. For example `folder/image.png`.
515
  ///   - options: Transform the asset before serving it to the client.
516
  ///   - additionalQueryItems: Additional query items to be added to the request.
517
  ///   - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
518
  /// - Returns: The data of the downloaded file.
519
  @discardableResult
520
  public func download(
521
    path: String,
522
    options: TransformOptions? = nil,
523
    query additionalQueryItems: [URLQueryItem]? = nil,
524
    cacheNonce: String? = nil
525
  ) async throws -> Data {
5✔
526
    var queryItems = options?.queryItems ?? []
5✔
527
    let renderPath =
5✔
528
      options.map { !$0.isEmpty } == true
5✔
529
      ? "render/image/authenticated" : "object"
5✔
530
    let _path = _getFinalPath(path)
5✔
531

5✔
532
    if let additionalQueryItems {
5✔
533
      queryItems.append(contentsOf: additionalQueryItems)
1✔
534
    }
1✔
535

5✔
536
    if let cacheNonce {
5✔
537
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
538
    }
1✔
539

5✔
540
    let (data, _) = try await client.fetchData(
5✔
541
      .get,
5✔
542
      url: storageURL(path: "\(renderPath)/\(_path)", queryItems: queryItems)
5✔
543
    )
5✔
544
    return data
5✔
545
  }
5✔
546

547
  /// Retrieves the details of an existing file.
548
  public func info(path: String) async throws -> FileObjectV2 {
1✔
549
    let _path = _getFinalPath(path)
1✔
550

1✔
551
    return try await client.fetchDecoded(.get, "object/info/\(_path)")
1✔
552
  }
1✔
553

554
  /// Checks the existence of file.
555
  public func exists(path: String) async throws -> Bool {
3✔
556
    do {
3✔
557
      try await client.fetchData(.head, "object/\(bucketId)/\(path)")
3✔
558
      return true
1✔
559
    } catch {
3✔
560
      var statusCode: Int?
2✔
561

2✔
562
      if let error = error as? StorageError {
2✔
563
        statusCode = error.statusCode.flatMap(Int.init)
×
564
      } else if let error = error as? HTTPError {
2✔
565
        statusCode = error.response.statusCode
2✔
566
      } else if case HTTPClientError.responseError(let response, _) = error {
2✔
NEW
567
        statusCode = response.statusCode
×
UNCOV
568
      }
×
569

2✔
570
      if let statusCode, [400, 404].contains(statusCode) {
2✔
571
        return false
2✔
572
      }
2✔
573

×
574
      throw error
×
575
    }
2✔
576
  }
3✔
577

578
  /// A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset.
579
  /// - Parameters:
580
  ///  - path: The path and name of the file to generate the public URL for. For example `folder/image.png`.
581
  ///  - download: Trigger a download with the specified file name.
582
  ///  - options: Transform the asset before retrieving it on the client.
583
  ///  - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
584
  ///
585
  ///  - Note: The bucket needs to be set to public, either via ``StorageBucketApi/updateBucket(_:options:)`` or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public".
586
  public func getPublicURL(
587
    path: String,
588
    download: String? = nil,
589
    options: TransformOptions? = nil,
590
    cacheNonce: String? = nil
591
  ) throws -> URL {
7✔
592
    var queryItems: [URLQueryItem] = []
7✔
593

7✔
594
    guard
7✔
595
      var components = URLComponents(
7✔
596
        url: client.configuration.url,
7✔
597
        resolvingAgainstBaseURL: true
7✔
598
      )
7✔
599
    else {
7✔
600
      throw URLError(.badURL)
×
601
    }
7✔
602

7✔
603
    if let download {
7✔
604
      queryItems.append(URLQueryItem(name: "download", value: download))
3✔
605
    }
3✔
606

7✔
607
    if let optionsQueryItems = options?.queryItems {
7✔
608
      queryItems.append(contentsOf: optionsQueryItems)
3✔
609
    }
3✔
610

7✔
611
    if let cacheNonce {
7✔
612
      queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce))
1✔
613
    }
1✔
614

7✔
615
    let renderPath =
7✔
616
      options.map { !$0.isEmpty } == true ? "render/image" : "object"
7✔
617

7✔
618
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
7✔
619
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
7✔
620

7✔
621
    guard let generatedUrl = components.url else {
7✔
622
      throw URLError(.badURL)
×
623
    }
7✔
624

7✔
625
    return generatedUrl
7✔
626
  }
7✔
627

628
  /// A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset.
629
  /// - Parameters:
630
  ///  - path: The path and name of the file to generate the public URL for. For example `folder/image.png`.
631
  ///  - download: Trigger a download with the default file name.
632
  ///  - options: Transform the asset before retrieving it on the client.
633
  ///  - cacheNonce: A nonce value appended as a `cacheNonce` query parameter for cache invalidation.
634
  ///
635
  ///  - Note: The bucket needs to be set to public, either via ``StorageBucketApi/updateBucket(_:options:)`` or by going to Storage on [supabase.com/dashboard](https://supabase.com/dashboard), clicking the overflow menu on a bucket and choosing "Make public".
636
  public func getPublicURL(
637
    path: String,
638
    download: Bool,
639
    options: TransformOptions? = nil,
640
    cacheNonce: String? = nil
641
  ) throws -> URL {
1✔
642
    try getPublicURL(
1✔
643
      path: path,
1✔
644
      download: download ? "" : nil,
1✔
645
      options: options,
1✔
646
      cacheNonce: cacheNonce
1✔
647
    )
1✔
648
  }
1✔
649

650
  /// Creates a signed upload URL. Signed upload URLs can be used to upload files to the bucket without further authentication. They are valid for 2 hours.
651
  /// - Parameter path: The file path, including the current file name. For example `folder/image.png`.
652
  /// - Returns: A URL that can be used to upload files to the bucket without further
653
  /// authentication.
654
  public func createSignedUploadURL(
655
    path: String,
656
    options: CreateSignedUploadURLOptions? = nil
657
  ) async throws -> SignedUploadURL {
2✔
658
    struct Response: Decodable {
2✔
659
      let url: String
2✔
660
    }
2✔
661

2✔
662
    var headers = [String: String]()
2✔
663
    if let upsert = options?.upsert, upsert {
2✔
664
      headers[Header.xUpsert] = "true"
1✔
665
    }
1✔
666

2✔
667
    let response: Response = try await client.fetchDecoded(
2✔
668
      .post,
2✔
669
      "object/upload/sign/\(bucketId)/\(path)",
2✔
670
      headers: headers
2✔
671
    )
2✔
672

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

2✔
675
    guard
2✔
676
      let components = URLComponents(
2✔
677
        url: signedURL,
2✔
678
        resolvingAgainstBaseURL: false
2✔
679
      )
2✔
680
    else {
2✔
UNCOV
681
      throw URLError(.badURL)
×
682
    }
2✔
683

2✔
684
    guard
2✔
685
      let token = components.queryItems?.first(where: { $0.name == "token" })?
2✔
686
        .value
2✔
687
    else {
2✔
NEW
688
      throw StorageError(
×
NEW
689
        statusCode: nil,
×
NEW
690
        message: "No token returned by API",
×
NEW
691
        error: nil
×
NEW
692
      )
×
693
    }
2✔
694

2✔
695
    guard let url = components.url else {
2✔
696
      throw URLError(.badURL)
×
697
    }
2✔
698

2✔
699
    return SignedUploadURL(
2✔
700
      signedURL: url,
2✔
701
      path: path,
2✔
702
      token: token
2✔
703
    )
2✔
704
  }
2✔
705

706
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
707
  /// - Parameters:
708
  ///   - path: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
709
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
710
  ///   - data: The Data to be stored in the bucket.
711
  ///   - options: HTTP headers, for example `cacheControl`.
712
  /// - Returns: A key pointing to stored location.
713
  @discardableResult
714
  public func uploadToSignedURL(
715
    _ path: String,
716
    token: String,
717
    data: Data,
718
    options: FileOptions? = nil
719
  ) async throws -> SignedURLUploadResponse {
1✔
720
    try await _uploadToSignedURL(
1✔
721
      path: path,
1✔
722
      token: token,
1✔
723
      file: .data(data),
1✔
724
      options: options
1✔
725
    )
1✔
726
  }
1✔
727

728
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
729
  /// - Parameters:
730
  ///   - path: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
731
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
732
  ///   - fileURL: The file URL to be stored in the bucket.
733
  ///   - options: HTTP headers, for example `cacheControl`.
734
  /// - Returns: A key pointing to stored location.
735
  @discardableResult
736
  public func uploadToSignedURL(
737
    _ path: String,
738
    token: String,
739
    fileURL: URL,
740
    options: FileOptions? = nil
741
  ) async throws -> SignedURLUploadResponse {
2✔
742
    try await _uploadToSignedURL(
2✔
743
      path: path,
2✔
744
      token: token,
2✔
745
      file: .url(fileURL),
2✔
746
      options: options
2✔
747
    )
2✔
748
  }
2✔
749

750
  private func _uploadToSignedURL(
751
    path: String,
752
    token: String,
753
    file: FileUpload,
754
    options: FileOptions?
755
  ) async throws -> SignedURLUploadResponse {
3✔
756
    let options = options ?? file.defaultOptions()
3✔
757
    var headers = multipartHeaders(options: options)
3✔
758
    headers[Header.xUpsert] = "\(options.upsert)"
3✔
759

3✔
760
    let response: SignedUploadResponse = try await uploadMultipart(
3✔
761
      .put,
3✔
762
      url: storageURL(
3✔
763
        path: "object/upload/sign/\(bucketId)/\(path)",
3✔
764
        queryItems: [URLQueryItem(name: "token", value: token)]
3✔
765
      ),
3✔
766
      path: path,
3✔
767
      file: file,
3✔
768
      options: options,
3✔
769
      headers: headers
3✔
770
    )
3✔
771

3✔
772
    return SignedURLUploadResponse(path: path, fullPath: response.Key)
3✔
773
  }
3✔
774

775
  private func uploadMultipart<Response: Decodable>(
776
    _ method: HTTPMethod,
777
    url: URL,
778
    path: String,
779
    file: FileUpload,
780
    options: FileOptions,
781
    headers: [String: String]
782
  ) async throws -> Response {
7✔
783
    #if DEBUG
784
      let builder = MultipartBuilder(
7✔
785
        boundary: testingBoundary.value ?? "----sb-\(UUID().uuidString)")
7✔
786
    #else
787
      let builder = MultipartBuilder()
788
    #endif
789

7✔
790
    let multipart = file.append(
7✔
791
      to: builder,
7✔
792
      withPath: path,
7✔
793
      options: options
7✔
794
    )
7✔
795

7✔
796
    var headers = headers
7✔
797
    headers[Header.contentType] = multipart.contentType
7✔
798

7✔
799
    let request = try await client.http.createRequest(
7✔
800
      method,
7✔
801
      url: url,
7✔
802
      headers: client.mergedHeaders(headers)
7✔
803
    )
7✔
804

7✔
805
    do {
7✔
806
      client.logRequest(method, url: url)
7✔
807
      let data: Data
7✔
808
      let response: URLResponse
7✔
809

7✔
810
      if try file.usesTempFileUpload {
7✔
NEW
811
        let tempFile = try multipart.buildToTempFile()
×
NEW
812
        defer { try? FileManager.default.removeItem(at: tempFile) }
×
NEW
813

×
NEW
814
        (data, response) = try await client.http.session.upload(
×
NEW
815
          for: request,
×
NEW
816
          fromFile: tempFile
×
NEW
817
        )
×
818
      } else {
7✔
819
        (data, response) = try await client.http.session.upload(
7✔
820
          for: request,
7✔
821
          from: try multipart.buildInMemory()
7✔
822
        )
7✔
823
      }
7✔
824

7✔
825
      let httpResponse = try client.http.validateResponse(response, data: data)
7✔
826
      client.logResponse(httpResponse, data: data)
7✔
827
      return try client.configuration.decoder.decode(Response.self, from: data)
7✔
828
    } catch {
7✔
NEW
829
      client.logFailure(error)
×
NEW
830
      throw translateStorageError(error)
×
UNCOV
831
    }
×
832
  }
7✔
833

834
  private func multipartHeaders(options: FileOptions) -> [String: String] {
7✔
835
    var headers = options.headers ?? [:]
7✔
836
    headers.setIfMissing(Header.cacheControl, value: "max-age=\(options.cacheControl)")
7✔
837

7✔
838
    if let duplex = options.duplex {
7✔
NEW
839
      headers[Header.duplex] = duplex
×
NEW
840
    }
×
841

7✔
842
    return headers
7✔
843
  }
7✔
844

845
  private func storageURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
8✔
846
    var components = URLComponents(
8✔
847
      url: client.configuration.url.appendingPathComponent(path),
8✔
848
      resolvingAgainstBaseURL: false
8✔
849
    )
8✔
850
    components?.queryItems = queryItems.isEmpty ? nil : queryItems
8✔
851

8✔
852
    guard let url = components?.url else {
8✔
NEW
853
      throw URLError(.badURL)
×
854
    }
8✔
855

8✔
856
    return url
8✔
857
  }
8✔
858

NEW
859
  private func translateStorageError(_ error: any Error) -> any Error {
×
NEW
860
    guard case HTTPClientError.responseError(let response, let data) = error else {
×
NEW
861
      return error
×
NEW
862
    }
×
NEW
863

×
NEW
864
    if let storageError = try? client.configuration.decoder.decode(StorageError.self, from: data) {
×
NEW
865
      return storageError
×
NEW
866
    }
×
NEW
867

×
NEW
868
    return HTTPError(data: data, response: response)
×
UNCOV
869
  }
×
870

871
  private func _getFinalPath(_ path: String) -> String {
10✔
872
    "\(bucketId)/\(path)"
10✔
873
  }
10✔
874
}
875

876
func _removeEmptyFolders(_ path: String) -> String {
4✔
877
  let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
4✔
878
  let cleanedPath = trimmedPath.replacingOccurrences(
4✔
879
    of: "/+",
4✔
880
    with: "/",
4✔
881
    options: .regularExpression
4✔
882
  )
4✔
883
  return cleanedPath
4✔
884
}
4✔
885

886
extension [String: String] {
887
  fileprivate mutating func setIfMissing(_ key: String, value: String) {
7✔
888
    guard keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame }) == nil else {
7✔
NEW
889
      return
×
890
    }
7✔
891

7✔
892
    self[key] = value
7✔
893
  }
7✔
894
}
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