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

supabase / supabase-swift / 17296508409

28 Aug 2025 12:56PM UTC coverage: 66.092% (-11.3%) from 77.386%
17296508409

Pull #781

github

web-flow
Merge f6d0b4fc4 into e4d8c3718
Pull Request #781: RFC: Migrate HTTP networking from URLSession to Alamofire

867 of 1013 new or added lines in 27 files covered. (85.59%)

745 existing lines in 22 files now uncovered.

4674 of 7072 relevant lines covered (66.09%)

13.91 hits per line

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

93.14
/Sources/Storage/StorageFileApi.swift
1
import Alamofire
2
import Foundation
3
import HTTPTypes
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: "asc"
15
  )
16
)
17

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

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

28
  func encode(to formData: MultipartFormData, withPath path: String, options: FileOptions) {
4✔
29
    formData.append(
4✔
30
      options.cacheControl.data(using: .utf8)!,
4✔
31
      withName: "cacheControl"
4✔
32
    )
4✔
33

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

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

4✔
47
    case let .url(url):
4✔
48
      formData.append(url, withName: "")
2✔
49
    }
4✔
50
  }
4✔
51
}
52

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

58
/// Supabase Storage File API
59
public class StorageFileApi: StorageApi, @unchecked Sendable {
60
  /// The bucket id to operate on.
61
  let bucketId: String
62

63
  init(bucketId: String, configuration: StorageClientConfiguration) {
26✔
64
    self.bucketId = bucketId
26✔
65
    super.init(configuration: configuration)
26✔
66
  }
26✔
67

68
  private struct MoveResponse: Decodable {
69
    let message: String
70
  }
71

72
  private struct SignedURLResponse: Decodable {
73
    let signedURL: String
74
  }
75

76
  private func _uploadOrUpdate(
77
    method: HTTPTypes.HTTPRequest.Method,
78
    path: String,
79
    file: FileUpload,
80
    options: FileOptions?
81
  ) async throws -> FileUploadResponse {
2✔
82
    let options = options ?? defaultFileOptions
2✔
83
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
2✔
84

2✔
85
    if method == .post {
2✔
UNCOV
86
      headers[.xUpsert] = "\(options.upsert)"
×
UNCOV
87
    }
×
88

2✔
89
    headers[.duplex] = options.duplex
2✔
90

2✔
91
    #if DEBUG
92
      let formData = MultipartFormData(boundary: testingBoundary.value)
2✔
93
    #else
94
      let formData = MultipartFormData()
95
    #endif
96
    file.encode(to: formData, withPath: path, options: options)
2✔
97

2✔
98
    struct UploadResponse: Decodable {
2✔
99
      let Key: String
2✔
100
      let Id: String
2✔
101
    }
2✔
102

2✔
103
    let cleanPath = _removeEmptyFolders(path)
2✔
104
    let _path = _getFinalPath(cleanPath)
2✔
105

2✔
106
    let data = try await execute(
2✔
107
      HTTPRequest(
2✔
108
        url: configuration.url.appendingPathComponent("object/\(_path)"),
2✔
109
        method: method,
2✔
110
        query: [],
2✔
111
        formData: formData,
2✔
112
        options: options,
2✔
113
        headers: headers
2✔
114
      )
2✔
115
    )
2✔
116

2✔
117
    let response = try configuration.decoder.decode(UploadResponse.self, from: data)
2✔
118

2✔
119
    return FileUploadResponse(
2✔
120
      id: response.Id,
2✔
121
      path: path,
2✔
122
      fullPath: response.Key
2✔
123
    )
2✔
124
  }
2✔
125

126
  /// Uploads a file to an existing bucket.
127
  /// - Parameters:
128
  ///   - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
129
  ///   - data: The Data to be stored in the bucket.
130
  ///   - options: The options for the uploaded file.
131
  @discardableResult
132
  public func upload(
133
    _ path: String,
134
    data: Data,
135
    options: FileOptions = FileOptions()
UNCOV
136
  ) async throws -> FileUploadResponse {
×
UNCOV
137
    try await _uploadOrUpdate(
×
UNCOV
138
      method: .post,
×
UNCOV
139
      path: path,
×
UNCOV
140
      file: .data(data),
×
UNCOV
141
      options: options
×
UNCOV
142
    )
×
UNCOV
143
  }
×
144

145
  /// Uploads a file to an existing bucket.
146
  /// - Parameters:
147
  ///   - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
148
  ///   - fileURL: The file URL to be stored in the bucket.
149
  ///   - options: The options for the uploaded file.
150
  @discardableResult
151
  public func upload(
152
    _ path: String,
153
    fileURL: URL,
154
    options: FileOptions = FileOptions()
UNCOV
155
  ) async throws -> FileUploadResponse {
×
UNCOV
156
    try await _uploadOrUpdate(
×
UNCOV
157
      method: .post,
×
UNCOV
158
      path: path,
×
UNCOV
159
      file: .url(fileURL),
×
UNCOV
160
      options: options
×
UNCOV
161
    )
×
UNCOV
162
  }
×
163

164
  /// Replaces an existing file at the specified path with a new one.
165
  /// - Parameters:
166
  ///   - path: The relative file path. Should be of the format `folder/subfolder`. The bucket already exist before attempting to upload.
167
  ///   - data: The Data to be stored in the bucket.
168
  ///   - options: The options for the updated file.
169
  @discardableResult
170
  public func update(
171
    _ path: String,
172
    data: Data,
173
    options: FileOptions = FileOptions()
174
  ) async throws -> FileUploadResponse {
1✔
175
    try await _uploadOrUpdate(
1✔
176
      method: .put,
1✔
177
      path: path,
1✔
178
      file: .data(data),
1✔
179
      options: options
1✔
180
    )
1✔
181
  }
1✔
182

183
  /// Replaces an existing file at the specified path with a new one.
184
  /// - Parameters:
185
  ///   - path: The relative file path. Should be of the format `folder/subfolder`. The bucket already exist before attempting to upload.
186
  ///   - fileURL: The file URL to be stored in the bucket.
187
  ///   - options: The options for the updated file.
188
  @discardableResult
189
  public func update(
190
    _ path: String,
191
    fileURL: URL,
192
    options: FileOptions = FileOptions()
193
  ) async throws -> FileUploadResponse {
1✔
194
    try await _uploadOrUpdate(
1✔
195
      method: .put,
1✔
196
      path: path,
1✔
197
      file: .url(fileURL),
1✔
198
      options: options
1✔
199
    )
1✔
200
  }
1✔
201

202
  /// Moves an existing file to a new path.
203
  /// - Parameters:
204
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
205
  ///   - destination: The new file path, including the new file name. For example `folder/image-new.png`.
206
  ///   - options: The destination options.
207
  public func move(
208
    from source: String,
209
    to destination: String,
210
    options: DestinationOptions? = nil
211
  ) async throws {
3✔
212
    try await execute(
3✔
213
      HTTPRequest(
3✔
214
        url: configuration.url.appendingPathComponent("object/move"),
3✔
215
        method: .post,
3✔
216
        body: configuration.encoder.encode(
3✔
217
          [
3✔
218
            "bucketId": bucketId,
3✔
219
            "sourceKey": source,
3✔
220
            "destinationKey": destination,
3✔
221
            "destinationBucket": options?.destinationBucket,
3✔
222
          ]
3✔
223
        )
3✔
224
      )
3✔
225
    )
3✔
226
  }
1✔
227

228
  /// Copies an existing file to a new path.
229
  /// - Parameters:
230
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
231
  ///   - destination: The new file path, including the new file name. For example `folder/image-copy.png`.
232
  ///   - options: The destination options.
233
  @discardableResult
234
  public func copy(
235
    from source: String,
236
    to destination: String,
237
    options: DestinationOptions? = nil
238
  ) async throws -> String {
1✔
239
    struct UploadResponse: Decodable {
1✔
240
      let Key: String
1✔
241
    }
1✔
242

1✔
243
    let data = try await execute(
1✔
244
      HTTPRequest(
1✔
245
        url: configuration.url.appendingPathComponent("object/copy"),
1✔
246
        method: .post,
1✔
247
        body: configuration.encoder.encode(
1✔
248
          [
1✔
249
            "bucketId": bucketId,
1✔
250
            "sourceKey": source,
1✔
251
            "destinationKey": destination,
1✔
252
            "destinationBucket": options?.destinationBucket,
1✔
253
          ]
1✔
254
        )
1✔
255
      )
1✔
256
    )
1✔
257

1✔
258
    let response = try configuration.decoder.decode(UploadResponse.self, from: data)
1✔
259
    return response.Key
1✔
260
  }
1✔
261

262
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
263
  /// - Parameters:
264
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
265
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
266
  ///   - download: Trigger a download with the specified file name.
267
  ///   - transform: Transform the asset before serving it to the client.
268
  public func createSignedURL(
269
    path: String,
270
    expiresIn: Int,
271
    download: String? = nil,
272
    transform: TransformOptions? = nil
273
  ) async throws -> URL {
2✔
274
    struct Body: Encodable {
2✔
275
      let expiresIn: Int
2✔
276
      let transform: TransformOptions?
2✔
277
    }
2✔
278

2✔
279
    let encoder = JSONEncoder.unconfiguredEncoder
2✔
280

2✔
281
    let data = try await execute(
2✔
282
      HTTPRequest(
2✔
283
        url: configuration.url.appendingPathComponent("object/sign/\(bucketId)/\(path)"),
2✔
284
        method: .post,
2✔
285
        body: encoder.encode(
2✔
286
          Body(expiresIn: expiresIn, transform: transform)
2✔
287
        )
2✔
288
      )
2✔
289
    )
2✔
290

2✔
291
    let response = try configuration.decoder.decode(SignedURLResponse.self, from: data)
2✔
292

2✔
293
    return try makeSignedURL(response.signedURL, download: download)
2✔
294
  }
2✔
295

296
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
297
  /// - Parameters:
298
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
299
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
300
  ///   - download: Trigger a download with the default file name.
301
  ///   - transform: Transform the asset before serving it to the client.
302
  public func createSignedURL(
303
    path: String,
304
    expiresIn: Int,
305
    download: Bool,
306
    transform: TransformOptions? = nil
307
  ) async throws -> URL {
1✔
308
    try await createSignedURL(
1✔
309
      path: path,
1✔
310
      expiresIn: expiresIn,
1✔
311
      download: download ? "" : nil,
1✔
312
      transform: transform
1✔
313
    )
1✔
314
  }
1✔
315

316
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
317
  /// - Parameters:
318
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
319
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
320
  ///   - download: Trigger a download with the specified file name.
321
  public func createSignedURLs(
322
    paths: [String],
323
    expiresIn: Int,
324
    download: String? = nil
325
  ) async throws -> [URL] {
2✔
326
    struct Params: Encodable {
2✔
327
      let expiresIn: Int
2✔
328
      let paths: [String]
2✔
329
    }
2✔
330

2✔
331
    let encoder = JSONEncoder.unconfiguredEncoder
2✔
332

2✔
333
    let data = try await execute(
2✔
334
      HTTPRequest(
2✔
335
        url: configuration.url.appendingPathComponent("object/sign/\(bucketId)"),
2✔
336
        method: .post,
2✔
337
        body: encoder.encode(
2✔
338
          Params(expiresIn: expiresIn, paths: paths)
2✔
339
        )
2✔
340
      )
2✔
341
    )
2✔
342

2✔
343
    let response = try configuration.decoder.decode([SignedURLResponse].self, from: data)
2✔
344

2✔
345
    return try response.map { try makeSignedURL($0.signedURL, download: download) }
4✔
346
  }
2✔
347

348
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
349
  /// - Parameters:
350
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
351
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
352
  ///   - download: Trigger a download with the default file name.
353
  public func createSignedURLs(
354
    paths: [String],
355
    expiresIn: Int,
356
    download: Bool
357
  ) async throws -> [URL] {
1✔
358
    try await createSignedURLs(paths: paths, expiresIn: expiresIn, download: download ? "" : nil)
1✔
359
  }
1✔
360

361
  private func makeSignedURL(_ signedURL: String, download: String?) throws -> URL {
8✔
362
    guard let signedURLComponents = URLComponents(string: signedURL),
8✔
363
      var baseComponents = URLComponents(
8✔
364
        url: configuration.url, resolvingAgainstBaseURL: false)
8✔
365
    else {
8✔
366
      throw URLError(.badURL)
×
367
    }
8✔
368

8✔
369
    baseComponents.path +=
8✔
370
      signedURLComponents.path.hasPrefix("/")
8✔
371
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
8✔
372
    baseComponents.queryItems = signedURLComponents.queryItems
8✔
373

8✔
374
    if let download {
8✔
375
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
376
      baseComponents.queryItems!.append(URLQueryItem(name: "download", value: download))
3✔
377
    }
3✔
378

8✔
379
    guard let signedURL = baseComponents.url else {
8✔
380
      throw URLError(.badURL)
×
381
    }
8✔
382

8✔
383
    return signedURL
8✔
384
  }
8✔
385

386
  /// Deletes files within the same bucket
387
  /// - Parameters:
388
  ///   - paths: An array of files to be deletes, including the path and file name. For example [`folder/image.png`].
389
  /// - Returns: A list of removed ``FileObject``.
390
  @discardableResult
391
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
392
    let data = try await execute(
1✔
393
      HTTPRequest(
1✔
394
        url: configuration.url.appendingPathComponent("object/\(bucketId)"),
1✔
395
        method: .delete,
1✔
396
        body: configuration.encoder.encode(["prefixes": paths])
1✔
397
      )
1✔
398
    )
1✔
399

1✔
400
    return try configuration.decoder.decode([FileObject].self, from: data)
1✔
401
  }
1✔
402

403
  /// Lists all the files within a bucket.
404
  /// - Parameters:
405
  ///   - path: The folder path.
406
  ///   - options: Search options, including `limit`, `offset`, and `sortBy`.
407
  public func list(
408
    path: String? = nil,
409
    options: SearchOptions? = nil
410
  ) async throws -> [FileObject] {
1✔
411
    let encoder = JSONEncoder.unconfiguredEncoder
1✔
412

1✔
413
    var options = options ?? defaultSearchOptions
1✔
414
    options.prefix = path ?? ""
1✔
415

1✔
416
    let data = try await execute(
1✔
417
      HTTPRequest(
1✔
418
        url: configuration.url.appendingPathComponent("object/list/\(bucketId)"),
1✔
419
        method: .post,
1✔
420
        body: encoder.encode(options)
1✔
421
      )
1✔
422
    )
1✔
423

1✔
424
    return try configuration.decoder.decode([FileObject].self, from: data)
1✔
425
  }
1✔
426

427
  /// Downloads a file from a private bucket. For public buckets, make a request to the URL returned
428
  /// from ``StorageFileApi/getPublicURL(path:download:fileName:options:)`` instead.
429
  /// - Parameters:
430
  ///   - path: The file path to be downloaded, including the path and file name. For example `folder/image.png`.
431
  ///   - options: Transform the asset before serving it to the client.
432
  @discardableResult
433
  public func download(
434
    path: String,
435
    options: TransformOptions? = nil
436
  ) async throws -> Data {
2✔
437
    let queryItems = options?.queryItems ?? []
2✔
438
    let renderPath = options != nil ? "render/image/authenticated" : "object"
2✔
439
    let _path = _getFinalPath(path)
2✔
440

2✔
441
    return try await execute(
2✔
442
      HTTPRequest(
2✔
443
        url: configuration.url
2✔
444
          .appendingPathComponent("\(renderPath)/\(_path)"),
2✔
445
        method: .get,
2✔
446
        query: queryItems
2✔
447
      )
2✔
448
    )
2✔
449
  }
2✔
450

451
  /// Retrieves the details of an existing file.
452
  public func info(path: String) async throws -> FileObjectV2 {
1✔
453
    let _path = _getFinalPath(path)
1✔
454

1✔
455
    let data = try await execute(
1✔
456
      HTTPRequest(
1✔
457
        url: configuration.url.appendingPathComponent("object/info/\(_path)"),
1✔
458
        method: .get
1✔
459
      )
1✔
460
    )
1✔
461

1✔
462
    return try configuration.decoder.decode(FileObjectV2.self, from: data)
1✔
463
  }
1✔
464

465
  /// Checks the existence of file.
466
  public func exists(path: String) async throws -> Bool {
3✔
467
    do {
3✔
468
      try await execute(
3✔
469
        HTTPRequest(
3✔
470
          url: configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"),
3✔
471
          method: .head
3✔
472
        )
3✔
473
      )
3✔
474
      return true
1✔
475
    } catch AFError.responseValidationFailed(.customValidationFailed(let error)) {
3✔
476
      var statusCode: Int?
2✔
477

2✔
478
      if let error = error as? StorageError {
2✔
479
        statusCode = error.statusCode.flatMap(Int.init)
2✔
480
      } else if let error = error as? HTTPError {
2✔
UNCOV
481
        statusCode = error.response.statusCode
×
UNCOV
482
      }
×
483

2✔
484
      if let statusCode, [400, 404].contains(statusCode) {
2✔
485
        return false
2✔
486
      }
2✔
487

×
488
      throw error
×
489
    }
2✔
490
  }
3✔
491

492
  /// 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.
493
  /// - Parameters:
494
  ///  - path: The path and name of the file to generate the public URL for. For example `folder/image.png`.
495
  ///  - download: Trigger a download with the specified file name.
496
  ///  - options: Transform the asset before retrieving it on the client.
497
  ///
498
  ///  - 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".
499
  public func getPublicURL(
500
    path: String,
501
    download: String? = nil,
502
    options: TransformOptions? = nil
503
  ) throws -> URL {
4✔
504
    var queryItems: [URLQueryItem] = []
4✔
505

4✔
506
    guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true)
4✔
507
    else {
4✔
508
      throw URLError(.badURL)
×
509
    }
4✔
510

4✔
511
    if let download {
4✔
512
      queryItems.append(URLQueryItem(name: "download", value: download))
3✔
513
    }
3✔
514

4✔
515
    if let optionsQueryItems = options?.queryItems {
4✔
516
      queryItems.append(contentsOf: optionsQueryItems)
1✔
517
    }
1✔
518

4✔
519
    let renderPath = options != nil ? "render/image" : "object"
4✔
520

4✔
521
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
4✔
522
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
4✔
523

4✔
524
    guard let generatedUrl = components.url else {
4✔
525
      throw URLError(.badURL)
×
526
    }
4✔
527

4✔
528
    return generatedUrl
4✔
529
  }
4✔
530

531
  /// 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.
532
  /// - Parameters:
533
  ///  - path: The path and name of the file to generate the public URL for. For example `folder/image.png`.
534
  ///  - download: Trigger a download with the default file name.
535
  ///  - options: Transform the asset before retrieving it on the client.
536
  ///
537
  ///  - 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".
538
  public func getPublicURL(
539
    path: String,
540
    download: Bool,
541
    options: TransformOptions? = nil
542
  ) throws -> URL {
1✔
543
    try getPublicURL(path: path, download: download ? "" : nil, options: options)
1✔
544
  }
1✔
545

546
  /// 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.
547
  /// - Parameter path: The file path, including the current file name. For example `folder/image.png`.
548
  /// - Returns: A URL that can be used to upload files to the bucket without further
549
  /// authentication.
550
  public func createSignedUploadURL(
551
    path: String,
552
    options: CreateSignedUploadURLOptions? = nil
553
  ) async throws -> SignedUploadURL {
2✔
554
    struct Response: Decodable {
2✔
555
      let url: String
2✔
556
    }
2✔
557

2✔
558
    var headers = HTTPFields()
2✔
559
    if let upsert = options?.upsert, upsert {
2✔
560
      headers[.xUpsert] = "true"
1✔
561
    }
1✔
562

2✔
563
    let data = try await execute(
2✔
564
      HTTPRequest(
2✔
565
        url: configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
566
        method: .post,
2✔
567
        headers: headers
2✔
568
      )
2✔
569
    )
2✔
570

2✔
571
    let response = try configuration.decoder.decode(Response.self, from: data)
2✔
572

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

2✔
575
    guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
2✔
576
      throw URLError(.badURL)
×
577
    }
2✔
578

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

2✔
583
    guard let url = components.url else {
2✔
584
      throw URLError(.badURL)
×
585
    }
2✔
586

2✔
587
    return SignedUploadURL(
2✔
588
      signedURL: url,
2✔
589
      path: path,
2✔
590
      token: token
2✔
591
    )
2✔
592
  }
2✔
593

594
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
595
  /// - Parameters:
596
  ///   - 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.
597
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
598
  ///   - data: The Data to be stored in the bucket.
599
  ///   - options: HTTP headers, for example `cacheControl`.
600
  /// - Returns: A key pointing to stored location.
601
  @discardableResult
602
  public func uploadToSignedURL(
603
    _ path: String,
604
    token: String,
605
    data: Data,
606
    options: FileOptions? = nil
607
  ) async throws -> SignedURLUploadResponse {
1✔
608
    try await _uploadToSignedURL(
1✔
609
      path: path,
1✔
610
      token: token,
1✔
611
      file: .data(data),
1✔
612
      options: options
1✔
613
    )
1✔
614
  }
1✔
615

616
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
617
  /// - Parameters:
618
  ///   - 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.
619
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
620
  ///   - fileURL: The file URL to be stored in the bucket.
621
  ///   - options: HTTP headers, for example `cacheControl`.
622
  /// - Returns: A key pointing to stored location.
623
  @discardableResult
624
  public func uploadToSignedURL(
625
    _ path: String,
626
    token: String,
627
    fileURL: URL,
628
    options: FileOptions? = nil
629
  ) async throws -> SignedURLUploadResponse {
1✔
630
    try await _uploadToSignedURL(
1✔
631
      path: path,
1✔
632
      token: token,
1✔
633
      file: .url(fileURL),
1✔
634
      options: options
1✔
635
    )
1✔
636
  }
1✔
637

638
  private func _uploadToSignedURL(
639
    path: String,
640
    token: String,
641
    file: FileUpload,
642
    options: FileOptions?
643
  ) async throws -> SignedURLUploadResponse {
2✔
644
    let options = options ?? defaultFileOptions
2✔
645
    var headers = options.headers.map { HTTPFields($0) } ?? HTTPFields()
2✔
646

2✔
647
    headers[.xUpsert] = "\(options.upsert)"
2✔
648
    headers[.duplex] = options.duplex
2✔
649

2✔
650
    #if DEBUG
651
      let formData = MultipartFormData(boundary: testingBoundary.value)
2✔
652
    #else
653
      let formData = MultipartFormData()
654
    #endif
655
    file.encode(to: formData, withPath: path, options: options)
2✔
656

2✔
657
    struct UploadResponse: Decodable {
2✔
658
      let Key: String
2✔
659
    }
2✔
660

2✔
661
    let data = try await execute(
2✔
662
      HTTPRequest(
2✔
663
        url: configuration.url
2✔
664
          .appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
665
        method: .put,
2✔
666
        query: [URLQueryItem(name: "token", value: token)],
2✔
667
        formData: formData,
2✔
668
        options: options,
2✔
669
        headers: headers
2✔
670
      )
2✔
671
    )
2✔
672

2✔
673
    let response = try configuration.decoder.decode(UploadResponse.self, from: data)
2✔
674
    let fullPath = response.Key
2✔
675

2✔
676
    return SignedURLUploadResponse(path: path, fullPath: fullPath)
2✔
677
  }
2✔
678

679
  private func _getFinalPath(_ path: String) -> String {
5✔
680
    "\(bucketId)/\(path)"
5✔
681
  }
5✔
682

683
  private func _removeEmptyFolders(_ path: String) -> String {
2✔
684
    let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
2✔
685
    let cleanedPath = trimmedPath.replacingOccurrences(
2✔
686
      of: "/+", with: "/", options: .regularExpression
2✔
687
    )
2✔
688
    return cleanedPath
2✔
689
  }
2✔
690
}
691

692
extension HTTPField.Name {
693
  static let duplex = Self("duplex")!
694
  static let xUpsert = Self("x-upsert")!
695
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc