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

supabase / supabase-swift / 17321717294

29 Aug 2025 10:44AM UTC coverage: 78.634% (+1.2%) from 77.386%
17321717294

Pull #781

github

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

1027 of 1123 new or added lines in 27 files covered. (91.45%)

27 existing lines in 8 files now uncovered.

5156 of 6557 relevant lines covered (78.63%)

29.27 hits per line

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

97.15
/Sources/Storage/StorageFileApi.swift
1
import Alamofire
2
import Foundation
3

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

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

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

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

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

8✔
33
    if let metadata = options.metadata {
8✔
34
      formData.append(encodeMetadata(metadata), withName: "metadata")
5✔
35
    }
5✔
36

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

8✔
46
    case let .url(url):
8✔
47
      formData.append(url, withName: "")
3✔
48
    }
8✔
49
  }
8✔
50
}
51

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

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

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

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

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

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

6✔
84
    if method == .post {
6✔
85
      headers["x-upsert"] = "\(options.upsert)"
4✔
86
    }
4✔
87

6✔
88
    headers["duplex"] = options.duplex
6✔
89

6✔
90
    if headers["cache-control"] == nil {
6✔
91
      headers["cache-control"] = "max-age=\(options.cacheControl)"
6✔
92
    }
6✔
93

6✔
94
    struct UploadResponse: Decodable {
6✔
95
      let Key: String
6✔
96
      let Id: String
6✔
97
    }
6✔
98

6✔
99
    let cleanPath = _removeEmptyFolders(path)
6✔
100
    let _path = _getFinalPath(cleanPath)
6✔
101

6✔
102
    let response = try await upload(
6✔
103
      configuration.url.appendingPathComponent("object/\(_path)"),
6✔
104
      method: method,
6✔
105
      headers: headers
6✔
106
    ) { formData in
6✔
107
      file.encode(to: formData, withPath: path, options: options)
6✔
108
    }
6✔
109
    .serializingDecodable(UploadResponse.self, decoder: configuration.decoder)
6✔
110
    .value
6✔
111

6✔
112
    return FileUploadResponse(
6✔
113
      id: response.Id,
6✔
114
      path: path,
6✔
115
      fullPath: response.Key
6✔
116
    )
6✔
117
  }
6✔
118

119
  /// Uploads a file to an existing bucket.
120
  /// - Parameters:
121
  ///   - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload.
122
  ///   - data: The Data to be stored in the bucket.
123
  ///   - options: The options for the uploaded file.
124
  @discardableResult
125
  public func upload(
126
    _ path: String,
127
    data: Data,
128
    options: FileOptions = FileOptions()
129
  ) async throws -> FileUploadResponse {
3✔
130
    try await _uploadOrUpdate(
3✔
131
      method: .post,
3✔
132
      path: path,
3✔
133
      file: .data(data),
3✔
134
      options: options
3✔
135
    )
3✔
136
  }
2✔
137

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

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

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

195
  /// Moves an existing file to a new path.
196
  /// - Parameters:
197
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
198
  ///   - destination: The new file path, including the new file name. For example `folder/image-new.png`.
199
  ///   - options: The destination options.
200
  public func move(
201
    from source: String,
202
    to destination: String,
203
    options: DestinationOptions? = nil
204
  ) async throws {
3✔
205
    _ = try await execute(
3✔
206
      configuration.url.appendingPathComponent("object/move"),
3✔
207
      method: .post,
3✔
208
      body: [
3✔
209
        "bucketId": bucketId,
3✔
210
        "sourceKey": source,
3✔
211
        "destinationKey": destination,
3✔
212
        "destinationBucket": options?.destinationBucket,
3✔
213
      ]
3✔
214
    )
3✔
215
    .serializingData()
3✔
216
    .value
3✔
217
  }
3✔
218

219
  /// Copies an existing file to a new path.
220
  /// - Parameters:
221
  ///   - source: The original file path, including the current file name. For example `folder/image.png`.
222
  ///   - destination: The new file path, including the new file name. For example `folder/image-copy.png`.
223
  ///   - options: The destination options.
224
  @discardableResult
225
  public func copy(
226
    from source: String,
227
    to destination: String,
228
    options: DestinationOptions? = nil
229
  ) async throws -> String {
1✔
230
    struct UploadResponse: Decodable {
1✔
231
      let Key: String
1✔
232
    }
1✔
233

1✔
234
    let response = try await execute(
1✔
235
      configuration.url.appendingPathComponent("object/copy"),
1✔
236
      method: .post,
1✔
237
      body: [
1✔
238
        "bucketId": bucketId,
1✔
239
        "sourceKey": source,
1✔
240
        "destinationKey": destination,
1✔
241
        "destinationBucket": options?.destinationBucket,
1✔
242
      ]
1✔
243
    )
1✔
244
    .serializingDecodable(UploadResponse.self, decoder: configuration.decoder)
1✔
245
    .value
1✔
246

1✔
247
    return response.Key
1✔
248
  }
1✔
249

250
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
251
  /// - Parameters:
252
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
253
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
254
  ///   - download: Trigger a download with the specified file name.
255
  ///   - transform: Transform the asset before serving it to the client.
256
  public func createSignedURL(
257
    path: String,
258
    expiresIn: Int,
259
    download: String? = nil,
260
    transform: TransformOptions? = nil
261
  ) async throws -> URL {
2✔
262
    struct Body: Encodable {
2✔
263
      let expiresIn: Int
2✔
264
      let transform: TransformOptions?
2✔
265
    }
2✔
266

2✔
267
    let response = try await execute(
2✔
268
      configuration.url.appendingPathComponent("object/sign/\(bucketId)/\(path)"),
2✔
269
      method: .post,
2✔
270
      body: Body(expiresIn: expiresIn, transform: transform),
2✔
271
      encoder: JSONParameterEncoder(encoder: JSONEncoder.unconfiguredEncoder)
2✔
272
    ).serializingDecodable(SignedURLResponse.self, decoder: configuration.decoder).value
2✔
273

2✔
274
    return try makeSignedURL(response.signedURL, download: download)
2✔
275
  }
2✔
276

277
  /// Creates a signed URL. Use a signed URL to share a file for a fixed amount of time.
278
  /// - Parameters:
279
  ///   - path: The file path, including the current file name. For example `folder/image.png`.
280
  ///   - expiresIn: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute.
281
  ///   - download: Trigger a download with the default file name.
282
  ///   - transform: Transform the asset before serving it to the client.
283
  public func createSignedURL(
284
    path: String,
285
    expiresIn: Int,
286
    download: Bool,
287
    transform: TransformOptions? = nil
288
  ) async throws -> URL {
1✔
289
    try await createSignedURL(
1✔
290
      path: path,
1✔
291
      expiresIn: expiresIn,
1✔
292
      download: download ? "" : nil,
1✔
293
      transform: transform
1✔
294
    )
1✔
295
  }
1✔
296

297
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
298
  /// - Parameters:
299
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
300
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
301
  ///   - download: Trigger a download with the specified file name.
302
  public func createSignedURLs(
303
    paths: [String],
304
    expiresIn: Int,
305
    download: String? = nil
306
  ) async throws -> [URL] {
2✔
307
    struct Params: Encodable {
2✔
308
      let expiresIn: Int
2✔
309
      let paths: [String]
2✔
310
    }
2✔
311

2✔
312
    let response = try await execute(
2✔
313
      configuration.url.appendingPathComponent("object/sign/\(bucketId)"),
2✔
314
      method: .post,
2✔
315
      body: Params(expiresIn: expiresIn, paths: paths),
2✔
316
      encoder: JSONParameterEncoder(encoder: JSONEncoder.unconfiguredEncoder)
2✔
317
    ).serializingDecodable([SignedURLResponse].self, decoder: configuration.decoder).value
2✔
318

2✔
319
    return try response.map { try makeSignedURL($0.signedURL, download: download) }
4✔
320
  }
2✔
321

322
  /// Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time.
323
  /// - Parameters:
324
  ///   - paths: The file paths to be downloaded, including the current file names. For example `["folder/image.png", "folder2/image2.png"]`.
325
  ///   - expiresIn: The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute.
326
  ///   - download: Trigger a download with the default file name.
327
  public func createSignedURLs(
328
    paths: [String],
329
    expiresIn: Int,
330
    download: Bool
331
  ) async throws -> [URL] {
1✔
332
    try await createSignedURLs(paths: paths, expiresIn: expiresIn, download: download ? "" : nil)
1✔
333
  }
1✔
334

335
  private func makeSignedURL(_ signedURL: String, download: String?) throws -> URL {
8✔
336
    guard let signedURLComponents = URLComponents(string: signedURL),
8✔
337
      var baseComponents = URLComponents(
8✔
338
        url: configuration.url,
8✔
339
        resolvingAgainstBaseURL: false
8✔
340
      )
8✔
341
    else {
8✔
342
      throw URLError(.badURL)
×
343
    }
8✔
344

8✔
345
    baseComponents.path +=
8✔
346
      signedURLComponents.path.hasPrefix("/")
8✔
347
      ? signedURLComponents.path : "/\(signedURLComponents.path)"
8✔
348
    baseComponents.queryItems = signedURLComponents.queryItems
8✔
349

8✔
350
    if let download {
8✔
351
      baseComponents.queryItems = baseComponents.queryItems ?? []
3✔
352
      baseComponents.queryItems!.append(URLQueryItem(name: "download", value: download))
3✔
353
    }
3✔
354

8✔
355
    guard let signedURL = baseComponents.url else {
8✔
356
      throw URLError(.badURL)
×
357
    }
8✔
358

8✔
359
    return signedURL
8✔
360
  }
8✔
361

362
  /// Deletes files within the same bucket
363
  /// - Parameters:
364
  ///   - paths: An array of files to be deletes, including the path and file name. For example [`folder/image.png`].
365
  /// - Returns: A list of removed ``FileObject``.
366
  @discardableResult
367
  public func remove(paths: [String]) async throws -> [FileObject] {
1✔
368
    try await execute(
1✔
369
      configuration.url.appendingPathComponent("object/\(bucketId)"),
1✔
370
      method: .delete,
1✔
371
      body: ["prefixes": paths]
1✔
372
    ).serializingDecodable([FileObject].self, decoder: configuration.decoder).value
1✔
373
  }
1✔
374

375
  /// Lists all the files within a bucket.
376
  /// - Parameters:
377
  ///   - path: The folder path.
378
  ///   - options: Search options, including `limit`, `offset`, and `sortBy`.
379
  public func list(
380
    path: String? = nil,
381
    options: SearchOptions? = nil
382
  ) async throws -> [FileObject] {
1✔
383
    var options = options ?? defaultSearchOptions
1✔
384
    options.prefix = path ?? ""
1✔
385

1✔
386
    return try await execute(
1✔
387
      configuration.url.appendingPathComponent("object/list/\(bucketId)"),
1✔
388
      method: .post,
1✔
389
      body: options,
1✔
390
      encoder: JSONParameterEncoder(encoder: JSONEncoder.unconfiguredEncoder)
1✔
391
    ).serializingDecodable([FileObject].self, decoder: configuration.decoder).value
1✔
392
  }
1✔
393

394
  /// Downloads a file from a private bucket. For public buckets, make a request to the URL returned
395
  /// from ``StorageFileApi/getPublicURL(path:download:fileName:options:)`` instead.
396
  /// - Parameters:
397
  ///   - path: The file path to be downloaded, including the path and file name. For example `folder/image.png`.
398
  ///   - options: Transform the asset before serving it to the client.
399
  @discardableResult
400
  public func download(
401
    path: String,
402
    options: TransformOptions? = nil
403
  ) async throws -> Data {
2✔
404
    let queryItems = options?.queryItems ?? []
2✔
405
    let renderPath = options != nil ? "render/image/authenticated" : "object"
2✔
406
    let _path = _getFinalPath(path)
2✔
407

2✔
408
    return try await execute(
2✔
409
      configuration.url
2✔
410
        .appendingPathComponent("\(renderPath)/\(_path)"),
2✔
411
      method: .get,
2✔
412
      query: queryItems.reduce(into: [:]) { result, item in
2✔
413
        result[item.name] = item.value
2✔
414
      }
2✔
415
    ).serializingData().value
2✔
416
  }
2✔
417

418
  /// Retrieves the details of an existing file.
419
  public func info(path: String) async throws -> FileObjectV2 {
1✔
420
    let _path = _getFinalPath(path)
1✔
421

1✔
422
    return try await execute(
1✔
423
      configuration.url.appendingPathComponent("object/info/\(_path)"),
1✔
424
      method: .get
1✔
425
    ).serializingDecodable(FileObjectV2.self, decoder: configuration.decoder).value
1✔
426
  }
1✔
427

428
  /// Checks the existence of file.
429
  public func exists(path: String) async throws -> Bool {
3✔
430
    do {
3✔
431
      _ = try await execute(
3✔
432
        configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"),
3✔
433
        method: .head
3✔
434
      ).serializingData().value
3✔
435
      return true
3✔
436
    } catch AFError.responseValidationFailed(.customValidationFailed(let error)) {
3✔
437
      var statusCode: Int?
2✔
438

2✔
439
      if let error = error as? StorageError {
2✔
440
        statusCode = error.statusCode.flatMap(Int.init)
2✔
441
      } else if let error = error as? HTTPError {
2✔
UNCOV
442
        statusCode = error.response.statusCode
×
UNCOV
443
      }
×
444

2✔
445
      if let statusCode, [400, 404].contains(statusCode) {
2✔
446
        return false
2✔
447
      }
2✔
448

×
449
      throw error
×
450
    }
2✔
451
  }
3✔
452

453
  /// 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.
454
  /// - Parameters:
455
  ///  - path: The path and name of the file to generate the public URL for. For example `folder/image.png`.
456
  ///  - download: Trigger a download with the specified file name.
457
  ///  - options: Transform the asset before retrieving it on the client.
458
  ///
459
  ///  - 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".
460
  public func getPublicURL(
461
    path: String,
462
    download: String? = nil,
463
    options: TransformOptions? = nil
464
  ) throws -> URL {
4✔
465
    var queryItems: [URLQueryItem] = []
4✔
466

4✔
467
    guard var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: true)
4✔
468
    else {
4✔
469
      throw URLError(.badURL)
×
470
    }
4✔
471

4✔
472
    if let download {
4✔
473
      queryItems.append(URLQueryItem(name: "download", value: download))
3✔
474
    }
3✔
475

4✔
476
    if let optionsQueryItems = options?.queryItems {
4✔
477
      queryItems.append(contentsOf: optionsQueryItems)
1✔
478
    }
1✔
479

4✔
480
    let renderPath = options != nil ? "render/image" : "object"
4✔
481

4✔
482
    components.path += "/\(renderPath)/public/\(bucketId)/\(path)"
4✔
483
    components.queryItems = !queryItems.isEmpty ? queryItems : nil
4✔
484

4✔
485
    guard let generatedUrl = components.url else {
4✔
486
      throw URLError(.badURL)
×
487
    }
4✔
488

4✔
489
    return generatedUrl
4✔
490
  }
4✔
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 default 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: Bool,
502
    options: TransformOptions? = nil
503
  ) throws -> URL {
1✔
504
    try getPublicURL(path: path, download: download ? "" : nil, options: options)
1✔
505
  }
1✔
506

507
  /// 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.
508
  /// - Parameter path: The file path, including the current file name. For example `folder/image.png`.
509
  /// - Returns: A URL that can be used to upload files to the bucket without further
510
  /// authentication.
511
  public func createSignedUploadURL(
512
    path: String,
513
    options: CreateSignedUploadURLOptions? = nil
514
  ) async throws -> SignedUploadURL {
2✔
515
    struct Response: Decodable {
2✔
516
      let url: String
2✔
517
    }
2✔
518

2✔
519
    var headers = HTTPHeaders()
2✔
520
    if let upsert = options?.upsert, upsert {
2✔
521
      headers["x-upsert"] = "true"
1✔
522
    }
1✔
523

2✔
524
    let response = try await execute(
2✔
525
      configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
526
      method: .post,
2✔
527
      headers: headers
2✔
528
    ).serializingDecodable(Response.self, decoder: configuration.decoder).value
2✔
529

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

2✔
532
    guard let components = URLComponents(url: signedURL, resolvingAgainstBaseURL: false) else {
2✔
533
      throw URLError(.badURL)
×
534
    }
2✔
535

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

2✔
540
    guard let url = components.url else {
2✔
541
      throw URLError(.badURL)
×
542
    }
2✔
543

2✔
544
    return SignedUploadURL(
2✔
545
      signedURL: url,
2✔
546
      path: path,
2✔
547
      token: token
2✔
548
    )
2✔
549
  }
2✔
550

551
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
552
  /// - Parameters:
553
  ///   - 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.
554
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
555
  ///   - data: The Data to be stored in the bucket.
556
  ///   - options: HTTP headers, for example `cacheControl`.
557
  /// - Returns: A key pointing to stored location.
558
  @discardableResult
559
  public func uploadToSignedURL(
560
    _ path: String,
561
    token: String,
562
    data: Data,
563
    options: FileOptions? = nil
564
  ) async throws -> SignedURLUploadResponse {
1✔
565
    try await _uploadToSignedURL(
1✔
566
      path: path,
1✔
567
      token: token,
1✔
568
      file: .data(data),
1✔
569
      options: options
1✔
570
    )
1✔
571
  }
1✔
572

573
  /// Upload a file with a token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
574
  /// - Parameters:
575
  ///   - 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.
576
  ///   - token: The token generated from ``StorageFileApi/createSignedUploadURL(path:)``.
577
  ///   - fileURL: The file URL to be stored in the bucket.
578
  ///   - options: HTTP headers, for example `cacheControl`.
579
  /// - Returns: A key pointing to stored location.
580
  @discardableResult
581
  public func uploadToSignedURL(
582
    _ path: String,
583
    token: String,
584
    fileURL: URL,
585
    options: FileOptions? = nil
586
  ) async throws -> SignedURLUploadResponse {
1✔
587
    try await _uploadToSignedURL(
1✔
588
      path: path,
1✔
589
      token: token,
1✔
590
      file: .url(fileURL),
1✔
591
      options: options
1✔
592
    )
1✔
593
  }
1✔
594

595
  private func _uploadToSignedURL(
596
    path: String,
597
    token: String,
598
    file: FileUpload,
599
    options: FileOptions?
600
  ) async throws -> SignedURLUploadResponse {
2✔
601
    let options = options ?? defaultFileOptions
2✔
602
    var headers = options.headers.map { HTTPHeaders($0) } ?? HTTPHeaders()
2✔
603

2✔
604
    if headers["cache-control"] == nil {
2✔
605
      headers["cache-control"] = "max-age=\(options.cacheControl)"
2✔
606
    }
2✔
607

2✔
608
    headers["x-upsert"] = "\(options.upsert)"
2✔
609
    headers["duplex"] = options.duplex
2✔
610

2✔
611
    struct UploadResponse: Decodable {
2✔
612
      let Key: String
2✔
613
    }
2✔
614

2✔
615
    let response = try await upload(
2✔
616
      configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"),
2✔
617
      method: .put,
2✔
618
      headers: headers,
2✔
619
      query: ["token": token]
2✔
620
    ) { formData in
2✔
621
      file.encode(to: formData, withPath: path, options: options)
2✔
622
    }
2✔
623
    .serializingDecodable(UploadResponse.self, decoder: configuration.decoder)
2✔
624
    .value
2✔
625

2✔
626
    let fullPath = response.Key
2✔
627

2✔
628
    return SignedURLUploadResponse(path: path, fullPath: fullPath)
2✔
629
  }
2✔
630

631
  private func _getFinalPath(_ path: String) -> String {
9✔
632
    "\(bucketId)/\(path)"
9✔
633
  }
9✔
634

635
  private func _removeEmptyFolders(_ path: String) -> String {
6✔
636
    let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
6✔
637
    let cleanedPath = trimmedPath.replacingOccurrences(
6✔
638
      of: "/+",
6✔
639
      with: "/",
6✔
640
      options: .regularExpression
6✔
641
    )
6✔
642
    return cleanedPath
6✔
643
  }
6✔
644
}
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