Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Sources/Storage/Resumable/DiskResumableCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

final class DiskResumableCache: ResumableCache, @unchecked Sendable {
private let storage: FileManager

init(storage: FileManager) {
self.storage = storage
}

func set(fingerprint: Fingerprint, entry: ResumableCacheEntry) async throws {
let data = try JSONEncoder().encode(entry)
storage.createFile(atPath: fingerprint.value, contents: data)
}

func get(fingerprint: Fingerprint) async throws -> ResumableCacheEntry? {
let data = storage.contents(atPath: fingerprint.value)
guard let data = data else {
return nil
}
return try JSONDecoder().decode(ResumableCacheEntry.self, from: data)
}

func remove(fingerprint: Fingerprint) async throws {
try storage.removeItem(atPath: fingerprint.value)
}

func clear() async throws {
try storage.removeItem(atPath: storage.currentDirectoryPath)
}

func entries() async throws -> [CachePair] {
let files = try storage.contentsOfDirectory(atPath: storage.currentDirectoryPath)
return try files.compactMap { file -> CachePair? in
let data = storage.contents(atPath: file)
guard let data = data else {
return nil
}
return (
Fingerprint(value: file)!,
try JSONDecoder().decode(ResumableCacheEntry.self, from: data)
)
}
}
}
51 changes: 51 additions & 0 deletions Sources/Storage/Resumable/Fingerprint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

public struct Fingerprint: Hashable, Sendable {
let value: String

private static let fingerprintSeparator = "::"
private static let fingerprintParts = 2

private var parts: [String] {
value.components(separatedBy: Self.fingerprintSeparator)
}

var source: String {
parts[0]
}

var size: Int64 {
Int64(parts[1]) ?? 0
}

init(source: String, size: Int64) {
self.value = "\(source)\(Self.fingerprintSeparator)\(size)"
}

init?(value: String) {
let parts = value.components(separatedBy: Self.fingerprintSeparator)
guard parts.count == Self.fingerprintParts else { return nil }
self.init(source: parts[0], size: Int64(parts[1]) ?? 0)
}
}

extension Fingerprint: Codable {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
guard let fingerprint = Fingerprint(value: value) else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Invalid fingerprint format"
)
)
}
self = fingerprint
}

public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}
}
46 changes: 46 additions & 0 deletions Sources/Storage/Resumable/MemoryResumableCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

actor MemoryResumableCache: ResumableCache {
private var storage: [String: Data] = [:]

init() {}

func set(fingerprint: Fingerprint, entry: ResumableCacheEntry) async throws {
let data = try JSONEncoder().encode(entry)
storage[fingerprint.value] = data
}

func get(fingerprint: Fingerprint) async throws -> ResumableCacheEntry? {
guard let data = storage[fingerprint.value] else {
return nil
}
return try JSONDecoder().decode(ResumableCacheEntry.self, from: data)
}

func remove(fingerprint: Fingerprint) async throws {
storage.removeValue(forKey: fingerprint.value)
}

func clear() async throws {
storage.removeAll()
}

func entries() async throws -> [CachePair] {
var pairs: [CachePair] = []

for (key, data) in storage {
guard let fingerprint = Fingerprint(value: key) else {
continue
}

do {
let entry = try JSONDecoder().decode(ResumableCacheEntry.self, from: data)
pairs.append((fingerprint, entry))
} catch {
continue
}
}

return pairs
}
}
33 changes: 33 additions & 0 deletions Sources/Storage/Resumable/ResumableCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import Foundation

struct ResumableCacheEntry: Codable, Sendable {
let uploadURL: String
let path: String
let bucketId: String
let expiration: Date
let upsert: Bool
let contentType: String?

enum CodingKeys: String, CodingKey {
case uploadURL = "upload_url"
case path
case bucketId = "bucket_id"
case expiration
case upsert
case contentType = "content_type"
}
}

typealias CachePair = (Fingerprint, ResumableCacheEntry)

protocol ResumableCache: Sendable {
func set(fingerprint: Fingerprint, entry: ResumableCacheEntry) async throws
func get(fingerprint: Fingerprint) async throws -> ResumableCacheEntry?
func remove(fingerprint: Fingerprint) async throws
func clear() async throws
func entries() async throws -> [CachePair]
}

func createDefaultResumableCache() -> some ResumableCache {
DiskResumableCache(storage: FileManager.default)
}
143 changes: 143 additions & 0 deletions Sources/Storage/Resumable/ResumableClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Foundation
import HTTPTypes
import Helpers

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

protocol ResumableClient: Sendable {
static var tusVersion: String { get }

func createUpload(
fingerprint: Fingerprint,
path: String,
bucketId: String,
contentLength: Int64,
contentType: String?,
upsert: Bool,
metadata: [String: String]
) async throws -> ResumableCacheEntry

func continueUpload(
fingerprint: Fingerprint,
cacheEntry: ResumableCacheEntry
) async throws -> ResumableCacheEntry?
}

extension ResumableClient {
static var tusVersion: String { "1.0.0" }
}

final class ResumableClientImpl: ResumableClient, @unchecked Sendable {
static let tusVersion = "1.0.0"

private let storageApi: StorageApi
private let cache: any ResumableCache

init(storageApi: StorageApi, cache: any ResumableCache) {
self.storageApi = storageApi
self.cache = cache
}

func createUpload(
fingerprint: Fingerprint,
path: String,
bucketId: String,
contentLength: Int64,
contentType: String?,
upsert: Bool,
metadata: [String: String]
) async throws -> ResumableCacheEntry {
var uploadMetadata = metadata
uploadMetadata["filename"] = path.components(separatedBy: "/").last ?? path
uploadMetadata["filetype"] = contentType

let metadataString =
uploadMetadata
.map { "\($0.key) \(Data($0.value.utf8).base64EncodedString())" }
.joined(separator: ",")

var headers = HTTPFields()
headers[.tusResumable] = Self.tusVersion
headers[.uploadLength] = "\(contentLength)"
headers[.uploadMetadata] = metadataString
headers[.contentType] = "application/offset+octet-stream"

if upsert {
headers[.xUpsert] = "true"
}

let request = Helpers.HTTPRequest(
url: storageApi.configuration.url.appendingPathComponent("upload/resumable/\(bucketId)"),
method: .post,
headers: headers
)

let response = try await storageApi.execute(request)

guard let locationHeader = response.headers[.location],
let uploadURL = URL(string: locationHeader)
else {
throw StorageError(
statusCode: nil,
message: "No location header in TUS upload creation response",
error: nil
)
}

let expiration = Date().addingTimeInterval(3600) // 1 hour default
let cacheEntry = ResumableCacheEntry(
uploadURL: uploadURL.absoluteString,
path: path,
bucketId: bucketId,
expiration: expiration,
upsert: upsert,
contentType: contentType
)

try await cache.set(fingerprint: fingerprint, entry: cacheEntry)
return cacheEntry
}

func continueUpload(
fingerprint: Fingerprint,
cacheEntry: ResumableCacheEntry
) async throws -> ResumableCacheEntry? {
guard cacheEntry.expiration > Date() else {
try await cache.remove(fingerprint: fingerprint)
return nil
}

guard let uploadURL = URL(string: cacheEntry.uploadURL) else {
try await cache.remove(fingerprint: fingerprint)
return nil
}

var headers = HTTPFields()
headers[.tusResumable] = Self.tusVersion

let request = Helpers.HTTPRequest(
url: uploadURL,
method: .head,
headers: headers
)

do {
_ = try await storageApi.execute(request)
return cacheEntry
} catch {
try await cache.remove(fingerprint: fingerprint)
return nil
}
}
}

extension HTTPField.Name {
static let tusResumable = Self("tus-resumable")!
static let uploadLength = Self("upload-length")!
static let uploadOffset = Self("upload-offset")!
static let uploadMetadata = Self("upload-metadata")!
static let location = Self("location")!
static let contentType = Self("content-type")!
}
Loading
Loading