diff --git a/.github/workflows/tuskit-ci.yml b/.github/workflows/tuskit-ci.yml index 734b730b..dc2224a5 100644 --- a/.github/workflows/tuskit-ci.yml +++ b/.github/workflows/tuskit-ci.yml @@ -6,7 +6,7 @@ jobs: strategy: matrix: os: ["macos-latest"] - swift: ["5.10"] + swift: ["6.2"] runs-on: ${{ matrix.os }} steps: - name: Extract Branch Name diff --git a/.swift-version b/.swift-version new file mode 100644 index 00000000..4ac4fded --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.2.0 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..203e81bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Agent Guide + +## Purpose +Agents act as senior Swift collaborators. Keep responses concise, +clarify uncertainty before coding, and align suggestions with the rules linked below. + +## Rule Index +- @ai-rules/rule-loading.md — always load this file to understand which other files you need to load + +## Repository Overview +- Deep product and architecture context: @ai-docs/ +[Fill in by LLM assistant] + +## Commands +[Fill in by LLM assistant] +- `swiftformat . --config .swiftformat`: Apply formatting (run before committing) +- `swiftlint --config .swiftlint.yml`: Lint Swift sources and address custom rules +- `pre-commit run --all-files`: Verify hooks prior to pushing + +## Code Style +- Swift files use 4-space indentation, ≤180-character width, and always-trailing commas +- Inject dependencies (Point-Free Dependencies) instead of singletons; make impossible states unrepresentable +- Prefer shorthand optional binding syntax (e.g. `guard let handler`) instead of repeating the binding name + +## Architecture & Patterns +[Fill in by LLM assistant] +- Shared UI lives in `SharedViews`; shared models and utilities in `Shared*` modules +- Use dependency injection for all services and environment values to keep code testable + +## Key Integration Points +**Database**: [Fill in by LLM assistant] +**Services**: [Fill in by LLM assistant] +**Testing**: Swift Testing with `withDependencies` for deterministic test doubles +**UI**: [Fill in by LLM assistant] + +## Workflow +- Ask for clarification when requirements are ambiguous; surface 2–3 options when trade-offs matter +- Update documentation and related rules when introducing new patterns or services +- Use commits in `(): summary` format; squash fixups locally before sharing + +## Testing +[Fill in by LLM assistant] + +## Environment +[Fill in by LLM assistant] +- Requires SwiftUI, Combine, GRDB, and Point-Free Composable Architecture libraries +- Validate formatting and linting (swiftformat/swiftlint) before final review + +## Special Notes +- Do not mutate files outside the workspace root without explicit approval +- Avoid destructive git operations unless the user requests them directly +- When unsure or need to make a significant decision ASK the user for guidance +- Commit only things you modified yourself, someone else might be modyfing other files. diff --git a/README.md b/README.md index 1404250b..03d2d084 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,29 @@ To upload multiple files at once, you can use the `uploadFiles(filePaths:)` meth To specify a custom upload URL (e.g. for TransloadIt) or custom headers to be added to a file upload, please refer to the `uploadURL` and `customHeaders` properties in the methods related to uploading. Such as: `upload`, `uploadFileAt`, `uploadFiles` or `uploadMultiple(dataFiles:)`. +### Custom header generation + +Sometimes headers need to be signed or refreshed right before a request is sent. +`TUSClient` exposes a header generation hook so you can mutate previously supplied custom headers without rebuilding the upload. Pass the optional `generateHeaders` closure to the initializer and TUSKit will call it before every `POST`, `PATCH`, or `HEAD` request. + +```swift +let client = try TUSClient( + server: serverURL, + sessionIdentifier: "UploadSession", + sessionConfiguration: configuration, + storageDirectory: storageDirectory, + supportedExtensions: [.creation] +) { uploadID, headers, onHeadersGenerated in + tokenProvider.fetchToken(for: uploadID) { token in + var mutatedHeaders = headers + mutatedHeaders["Authorization"] = "Bearer \(token)" + onHeadersGenerated(mutatedHeaders) + } +} +``` + +TUSKit will reuse whatever headers you return for automatic retries or when resuming an upload, ensuring the same values are applied consistently. New headers can be introduced as needed, while core TUS headers such as `Upload-Offset` and `Content-Length` remain under the SDK’s control. + ## Measuring upload progress To know how many files have yet to be uploaded, please refer to the `remainingUploads` property. diff --git a/Sources/TUSKit/TUSAPI.swift b/Sources/TUSKit/TUSAPI.swift index f40dc61b..faaa1479 100644 --- a/Sources/TUSKit/TUSAPI.swift +++ b/Sources/TUSKit/TUSAPI.swift @@ -173,8 +173,8 @@ final class TUSAPI { /// - metaData: The file metadata. /// - completion: Completes with a result that gives a URL to upload to. @discardableResult - func create(metaData: UploadMetadata, completion: @escaping (Result) -> Void) -> URLSessionDataTask { - let request = makeCreateRequest(metaData: metaData) + func create(metaData: UploadMetadata, customHeaders: [String: String], completion: @escaping (Result) -> Void) -> URLSessionDataTask { + let request = makeCreateRequest(metaData: metaData, customHeaders: customHeaders) let identifier = UUID().uuidString let task = session.dataTask(with: request) task.taskDescription = identifier @@ -205,7 +205,7 @@ final class TUSAPI { return task } - func makeCreateRequest(metaData: UploadMetadata) -> URLRequest { + func makeCreateRequest(metaData: UploadMetadata, customHeaders: [String: String]) -> URLRequest { func makeUploadMetaHeader() -> [String: String] { var metaDataDict: [String: String] = [:] @@ -247,7 +247,7 @@ final class TUSAPI { } /// Attach all headers from customHeader property - let headers = defaultHeaders.merging(metaData.customHeaders ?? [:]) { _, new in new } + let headers = defaultHeaders.merging(customHeaders) { _, new in new } return makeRequest(url: metaData.uploadURL, method: .post, headers: headers) } @@ -259,7 +259,7 @@ final class TUSAPI { /// - location: The location of where to upload to. /// - completion: Completionhandler for when the upload is finished. @discardableResult - func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, completion: @escaping (Result) -> Void) -> URLSessionUploadTask { + func upload(data: Data, range: Range?, location: URL, metaData: UploadMetadata, customHeaders: [String: String], completion: @escaping (Result) -> Void) -> URLSessionUploadTask { let offset: Int let length: Int if let range = range { @@ -278,7 +278,7 @@ final class TUSAPI { ] /// Attach all headers from customHeader property - let headersWithCustom = headers.merging(metaData.customHeaders ?? [:]) { _, new in new } + let headersWithCustom = headers.merging(customHeaders) { _, new in new } let request = makeRequest(url: location, method: .patch, headers: headersWithCustom) let task = session.uploadTask(with: request, from: data) @@ -310,7 +310,7 @@ final class TUSAPI { return task } - func upload(fromFile file: URL, offset: Int = 0, location: URL, metaData: UploadMetadata, completion: @escaping (Result) -> Void) -> URLSessionUploadTask { + func upload(fromFile file: URL, offset: Int = 0, location: URL, metaData: UploadMetadata, customHeaders: [String: String], completion: @escaping (Result) -> Void) -> URLSessionUploadTask { let length: Int if let fileAttributes = try? FileManager.default.attributesOfItem(atPath: file.path) { if let bytes = fileAttributes[.size] as? Int64 { @@ -329,7 +329,7 @@ final class TUSAPI { ] /// Attach all headers from customHeader property - let headersWithCustom = headers.merging(metaData.customHeaders ?? [:]) { _, new in new } + let headersWithCustom = headers.merging(customHeaders) { _, new in new } let request = makeRequest(url: location, method: .patch, headers: headersWithCustom) let task = session.uploadTask(with: request, fromFile: file) @@ -503,4 +503,3 @@ private extension TUSAPI { } } } - diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 3900ebb2..b40fb23b 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -49,6 +49,8 @@ protocol ProgressDelegate: AnyObject { /// The TUSKit client. /// Please refer to the Readme.md on how to use this type. +public typealias HeaderGenerationHandler = (_ requestID: UUID, _ headers: [String: String], _ completion: @escaping ([String: String]) -> Void) -> Void + public final class TUSClient { // MARK: - Public Properties @@ -76,6 +78,7 @@ public final class TUSClient { private var uploads = [UUID: UploadMetadata]() private let queue = DispatchQueue(label: "com.TUSKit.TUSClient") private let reportingQueue: DispatchQueue + private let headerGenerator: HeaderGenerator /// Initialize a TUSClient with support for background URLSessions and uploads /// - Parameters: @@ -92,7 +95,8 @@ public final class TUSClient { /// - Throws: File related errors when it can't make a directory at the designated path. public init(server: URL, sessionIdentifier: String, sessionConfiguration: URLSessionConfiguration, storageDirectory: URL? = nil, chunkSize: Int = 500 * 1024, - supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main) throws { + supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main, + generateHeaders: HeaderGenerationHandler? = nil) throws { if #available(iOS 7.0, macOS 11.0, *) { if sessionConfiguration.sessionSendsLaunchEvents == false { @@ -116,6 +120,7 @@ public final class TUSClient { self.supportedExtensions = supportedExtensions self.scheduler = scheduler self.reportingQueue = reportingQueue + self.headerGenerator = HeaderGenerator(handler: generateHeaders) scheduler.delegate = self reregisterCallbacks() } @@ -136,7 +141,8 @@ public final class TUSClient { @available(*, deprecated, message: "Use the init(server:sessionIdentifier:sessionConfiguration:storageDirectory:chunkSize:supportedExtension) initializer instead.") public init(server: URL, sessionIdentifier: String, storageDirectory: URL? = nil, session: URLSession = URLSession.shared, chunkSize: Int = 500 * 1024, - supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main) throws { + supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main, + generateHeaders: HeaderGenerationHandler? = nil) throws { self.sessionIdentifier = sessionIdentifier self.api = TUSAPI(session: session) self.files = try Files(storageDirectory: storageDirectory) @@ -149,6 +155,7 @@ public final class TUSClient { self.supportedExtensions = supportedExtensions self.scheduler = Scheduler() self.reportingQueue = reportingQueue + self.headerGenerator = HeaderGenerator(handler: generateHeaders) scheduler.delegate = self removeFinishedUploads() reregisterCallbacks() @@ -169,7 +176,8 @@ public final class TUSClient { @available(iOS 15.0, macOS 12.0, *) public init(server: URL, storageDirectory: URL? = nil, session: URLSession = URLSession.shared, chunkSize: Int = 500 * 1024, - supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main) throws { + supportedExtensions: [TUSProtocolExtension] = [.creation], reportingQueue: DispatchQueue = DispatchQueue.main, + generateHeaders: HeaderGenerationHandler? = nil) throws { guard session.configuration.sessionSendsLaunchEvents == false else { throw TUSClientError.customURLSessionWithBackgroundConfigurationNotSupported } @@ -185,6 +193,7 @@ public final class TUSClient { self.supportedExtensions = supportedExtensions self.scheduler = Scheduler() self.reportingQueue = reportingQueue + self.headerGenerator = HeaderGenerator(handler: generateHeaders) scheduler.delegate = self removeFinishedUploads() reregisterCallbacks() @@ -340,6 +349,7 @@ public final class TUSClient { public func clearAllCache() throws { do { try files.clearCacheInStorageDirectory() + headerGenerator.clearAll() } catch let error { throw TUSClientError.couldNotDeleteFile(underlyingError: error) } @@ -358,6 +368,7 @@ public final class TUSClient { } try files.removeFileAndMetadata(metaData) + headerGenerator.clearHeaders(for: id) return true } catch let error { throw TUSClientError.couldNotDeleteFile(underlyingError: error) @@ -516,7 +527,7 @@ public final class TUSClient { return } guard taskExists, - let task = try? UploadDataTask(api: self.api, metaData: metadata, files: self.files) else { + let task = try? UploadDataTask(api: self.api, metaData: metadata, files: self.files, headerGenerator: self.headerGenerator) else { return } @@ -568,7 +579,7 @@ public final class TUSClient { } } - guard let task = try taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize, progressDelegate: self) else { + guard let task = try taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize, progressDelegate: self, headerGenerator: headerGenerator) else { assertionFailure("Could not find a task for metaData \(metaData)") return } @@ -627,7 +638,7 @@ public final class TUSClient { /// Schedule a single task if needed. Will decide what task to schedule for the metaData. /// - Parameter metaData:The metaData the schedule. private func scheduleTask(for metaData: UploadMetadata) throws { - guard let task = try taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize, progressDelegate: self) else { + guard let task = try taskFor(metaData: metaData, api: api, files: files, chunkSize: chunkSize, progressDelegate: self, headerGenerator: headerGenerator) else { throw TUSClientError.uploadIsAlreadyFinished } queue.sync { @@ -684,6 +695,7 @@ extension TUSClient: SchedulerDelegate { queue.sync { self.uploads[uploadTask.metaData.id] = nil } + headerGenerator.clearHeaders(for: uploadTask.metaData.id) reportingQueue.async { self.delegate?.didFinishUpload(id: uploadTask.metaData.id, url: url, context: uploadTask.metaData.context, client: self) } @@ -744,6 +756,7 @@ extension TUSClient: SchedulerDelegate { queue.sync { self.uploads[metaData.id] = nil } + headerGenerator.clearHeaders(for: metaData.id) reportingQueue.async { self.delegate?.uploadFailed(id: metaData.id, error: error, context: metaData.context, client: self) } @@ -766,17 +779,17 @@ private extension String { /// Decide which task to create based on metaData. /// - Parameter metaData: The `UploadMetadata` for which to create a `Task`. /// - Returns: The task that has to be performed for the relevant metaData. Will return nil if metaData's file is already uploaded / finished. (no task needed). -func taskFor(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int?, progressDelegate: ProgressDelegate? = nil) throws -> ScheduledTask? { +func taskFor(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int?, progressDelegate: ProgressDelegate? = nil, headerGenerator: HeaderGenerator) throws -> ScheduledTask? { guard !metaData.isFinished else { return nil } if let remoteDestination = metaData.remoteDestination { - let statusTask = StatusTask(api: api, remoteDestination: remoteDestination, metaData: metaData, files: files, chunkSize: chunkSize) + let statusTask = StatusTask(api: api, remoteDestination: remoteDestination, metaData: metaData, files: files, chunkSize: chunkSize, headerGenerator: headerGenerator) statusTask.progressDelegate = progressDelegate return statusTask } else { - let creationTask = try CreationTask(metaData: metaData, api: api, files: files, chunkSize: chunkSize) + let creationTask = try CreationTask(metaData: metaData, api: api, files: files, chunkSize: chunkSize, headerGenerator: headerGenerator) creationTask.progressDelegate = progressDelegate return creationTask } @@ -812,6 +825,78 @@ extension TUSClient: ProgressDelegate { } } +final class HeaderGenerator { + private let handler: HeaderGenerationHandler? + private let queue = DispatchQueue(label: "com.tuskit.headergenerator") + private var latestHeaders = [UUID: [String: String]]() + + init(handler: HeaderGenerationHandler?) { + self.handler = handler + } + + func resolveHeaders(for metaData: UploadMetadata, completion: @escaping ([String: String]) -> Void) { + let baseHeaders = storedHeaders(for: metaData) + guard let handler = handler else { + store(headers: baseHeaders, for: metaData) + completion(baseHeaders) + return + } + + handler(metaData.id, baseHeaders) { [weak self] headers in + guard let self else { + completion(baseHeaders) + return + } + var sanitized = baseHeaders + for (key, value) in headers { + let lowercasedKey = key.lowercased() + guard !HeaderGenerator.reservedHeaders.contains(lowercasedKey) else { continue } + sanitized[key] = value + } + self.store(headers: sanitized, for: metaData) + completion(sanitized) + } + } + + func clearHeaders(for id: UUID) { + queue.async { + self.latestHeaders.removeValue(forKey: id) + } + } + + func clearAll() { + queue.async { + self.latestHeaders.removeAll() + } + } + + private func storedHeaders(for metaData: UploadMetadata) -> [String: String] { + if let inMemory = queue.sync(execute: { latestHeaders[metaData.id] }) { + return inMemory + } + if let applied = metaData.appliedCustomHeaders { + return applied + } + return metaData.customHeaders ?? [:] + } + + private func store(headers: [String: String], for metaData: UploadMetadata) { + metaData.updateAppliedCustomHeaders(headers) + queue.async { + self.latestHeaders[metaData.id] = headers + } + } + + private static let reservedHeaders: Set = [ + "upload-length", + "upload-offset", + "upload-metadata", + "content-type", + "content-length", + "tus-resumable", + ] +} + private extension URL { var mimeType: String { let pathExtension = self.pathExtension @@ -823,4 +908,3 @@ private extension URL { return "application/octet-stream" } } - diff --git a/Sources/TUSKit/Tasks/CreationTask.swift b/Sources/TUSKit/Tasks/CreationTask.swift index 5958e42b..17ec5b3f 100644 --- a/Sources/TUSKit/Tasks/CreationTask.swift +++ b/Sources/TUSKit/Tasks/CreationTask.swift @@ -23,54 +23,64 @@ final class CreationTask: IdentifiableTask { private let api: TUSAPI private let files: Files private let chunkSize: Int? + private let headerGenerator: HeaderGenerator private var didCancel: Bool = false private weak var sessionTask: URLSessionDataTask? private let queue = DispatchQueue(label: "com.tuskit.creationtask") - init(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int? = nil) throws { + init(metaData: UploadMetadata, api: TUSAPI, files: Files, chunkSize: Int? = nil, headerGenerator: HeaderGenerator) throws { self.metaData = metaData self.api = api self.files = files self.chunkSize = chunkSize + self.headerGenerator = headerGenerator } func run(completed: @escaping TaskCompletion) { queue.async { if self.didCancel { return } - self.sessionTask = self.api.create(metaData: self.metaData) { [weak self] result in + self.headerGenerator.resolveHeaders(for: self.metaData) { [weak self] customHeaders in guard let self else { return } - // File is created remotely. Now start first datatask. self.queue.async { - let metaData = self.metaData - let files = self.files - let chunkSize = self.chunkSize - let api = self.api - let progressDelegate = self.progressDelegate + if self.didCancel { return } - do { - let remoteDestination = try result.get() - metaData.remoteDestination = remoteDestination - try files.encodeAndStore(metaData: metaData) - let task: UploadDataTask - if let chunkSize = chunkSize { - let newRange = 0.. metaData.size { - throw TUSClientError.fileSizeMismatchWithServer - } + self.queue.async { + // Getting rid of self. in this closure + let metaData = self.metaData + let files = self.files + let chunkSize = self.chunkSize + let api = self.api + let progressDelegate = self.progressDelegate - metaData.uploadedRange = 0.. metaData.size { + throw TUSClientError.fileSizeMismatchWithServer + } - if offset == metaData.size { - completed(.success([])) - } else { - // If the task has been canceled - // we don't continue to create subsequent UploadDataTasks - if self.didCancel { - throw TUSClientError.taskCancelled - } + metaData.uploadedRange = 0.. - if let chunkSize { - nextRange = offset.. + if let chunkSize { + nextRange = offset..? private var observation: NSKeyValueObservation? private weak var sessionTask: URLSessionUploadTask? + private let headerGenerator: HeaderGenerator /// Specify range, or upload /// - Parameters: @@ -36,10 +37,11 @@ final class UploadDataTask: NSObject, IdentifiableTask { /// - metaData: The metadata of the file to upload /// - range: Specify range to upload. If omitted, will upload entire file at once. /// - Throws: File and network related errors - init(api: TUSAPI, metaData: UploadMetadata, files: Files, range: Range? = nil) throws { + init(api: TUSAPI, metaData: UploadMetadata, files: Files, range: Range? = nil, headerGenerator: HeaderGenerator) throws { self.api = api self.metaData = metaData self.files = files + self.headerGenerator = headerGenerator if let range = range, range.count == 0 { // Improve: Enrich error @@ -91,25 +93,37 @@ final class UploadDataTask: NSObject, IdentifiableTask { return } - let task = self.api.upload(fromFile: file, - offset: self.range?.lowerBound ?? 0, - location: remoteDestination, - metaData: self.metaData) { [weak self] result in + self.headerGenerator.resolveHeaders(for: self.metaData) { [weak self] customHeaders in guard let self else { return } self.queue.async { - self.observation?.invalidate() - self.taskCompleted(result: result, completed: completed) - } - } + if self.isCanceled { + completed(.failure(TUSClientError.taskCancelled)) + return + } - task.taskDescription = "\(self.metaData.id)" - task.resume() + let task = self.api.upload(fromFile: file, + offset: self.range?.lowerBound ?? 0, + location: remoteDestination, + metaData: self.metaData, + customHeaders: customHeaders) { [weak self] result in + guard let self else { return } + + self.queue.async { + self.observation?.invalidate() + self.taskCompleted(result: result, completed: completed) + } + } - self.sessionTask = task + task.taskDescription = "\(self.metaData.id)" + task.resume() - if #available(iOS 11.0, macOS 10.13, *) { - self.observeTask(task: task, size: self.range?.count ?? dataSize) + self.sessionTask = task + + if #available(iOS 11.0, macOS 10.13, *) { + self.observeTask(task: task, size: self.range?.count ?? dataSize) + } + } } } } @@ -147,7 +161,7 @@ final class UploadDataTask: NSObject, IdentifiableTask { nextRange = nil } - let task = try UploadDataTask(api: api, metaData: metaData, files: files, range: nextRange) + let task = try UploadDataTask(api: api, metaData: metaData, files: files, range: nextRange, headerGenerator: headerGenerator) task.progressDelegate = progressDelegate completed(.success([task])) } catch let error as TUSClientError { diff --git a/Sources/TUSKit/UploadMetada.swift b/Sources/TUSKit/UploadMetada.swift index cef79171..cd493027 100644 --- a/Sources/TUSKit/UploadMetada.swift +++ b/Sources/TUSKit/UploadMetada.swift @@ -26,6 +26,7 @@ final class UploadMetadata: Codable { case customHeaders case size case errorCount + case appliedCustomHeaders } @@ -83,8 +84,19 @@ final class UploadMetadata: Codable { let mimeType: String? - let customHeaders: [String: String]? + private var _customHeaders: [String: String]? + var customHeaders: [String: String]? { + queue.sync { + _customHeaders + } + } let size: Int + private var _appliedCustomHeaders: [String: String]? + var appliedCustomHeaders: [String: String]? { + queue.sync { + _appliedCustomHeaders + } + } private var _errorCount: Int /// Number of times the upload failed @@ -105,11 +117,12 @@ final class UploadMetadata: Codable { self._filePath = filePath self.uploadURL = uploadURL self.size = size - self.customHeaders = customHeaders + self._customHeaders = customHeaders self.mimeType = mimeType self.version = 1 // Can't make default property because of Codable self.context = context self._errorCount = 0 + self._appliedCustomHeaders = nil } init(from decoder: Decoder) throws { @@ -122,9 +135,10 @@ final class UploadMetadata: Codable { context = try values.decode([String: String]?.self, forKey: .context) _uploadedRange = try values.decode(Range?.self, forKey: .uploadedRange) mimeType = try values.decode(String?.self, forKey: .mimeType) - customHeaders = try values.decode([String: String]?.self, forKey: .customHeaders) + _customHeaders = try values.decode([String: String]?.self, forKey: .customHeaders) size = try values.decode(Int.self, forKey: .size) _errorCount = try values.decode(Int.self, forKey: .errorCount) + _appliedCustomHeaders = try values.decode([String: String]?.self, forKey: .appliedCustomHeaders) } func encode(to encoder: Encoder) throws { @@ -137,9 +151,16 @@ final class UploadMetadata: Codable { try container.encode(context, forKey: .context) try container.encode(uploadedRange, forKey: .uploadedRange) try container.encode(mimeType, forKey: .mimeType) - try container.encode(customHeaders, forKey: .customHeaders) + try container.encode(_customHeaders, forKey: .customHeaders) try container.encode(size, forKey: .size) try container.encode(_errorCount, forKey: .errorCount) + try container.encode(_appliedCustomHeaders, forKey: .appliedCustomHeaders) + } + + func updateAppliedCustomHeaders(_ headers: [String: String]?) { + queue.async { + self._appliedCustomHeaders = headers + } } } diff --git a/Tests/TUSKitTests/TUSAPITests.swift b/Tests/TUSKitTests/TUSAPITests.swift index 4d83ffa1..281d5537 100644 --- a/Tests/TUSKitTests/TUSAPITests.swift +++ b/Tests/TUSKitTests/TUSAPITests.swift @@ -70,7 +70,7 @@ final class TUSAPITests: XCTestCase { filePath: URL(string: "file://whatever/abc")!, uploadURL: URL(string: "https://io.tus")!, size: size) - api.create(metaData: metaData) { result in + api.create(metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { result in do { let url = try result.get() XCTAssertEqual(url, remoteFileURL) @@ -108,7 +108,7 @@ final class TUSAPITests: XCTestCase { filePath: URL(string: "file://whatever/abc")!, uploadURL: uploadURL, size: size) - api.create(metaData: metaData) { result in + api.create(metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { result in do { let url = try result.get() XCTAssertEqual(url.absoluteURL, expectedURL) @@ -147,7 +147,7 @@ final class TUSAPITests: XCTestCase { uploadURL: URL(string: "io.tus")!, size: length) - let task = api.upload(data: Data(), range: range, location: uploadURL, metaData: metaData) { _ in + let task = api.upload(data: Data(), range: range, location: uploadURL, metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { _ in uploadExpectation.fulfill() } XCTAssertEqual(task.originalRequest?.url, uploadURL) @@ -185,7 +185,7 @@ final class TUSAPITests: XCTestCase { uploadURL: URL(string: "io.tus")!, size: length) - let task = api.upload(data: Data(), range: range, location: uploadURL, metaData: metaData) { _ in + let task = api.upload(data: Data(), range: range, location: uploadURL, metaData: metaData, customHeaders: metaData.customHeaders ?? [:]) { _ in uploadExpectation.fulfill() } XCTAssertEqual(task.originalRequest?.url, expectedURL) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift new file mode 100644 index 00000000..5e2bdbc7 --- /dev/null +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -0,0 +1,338 @@ +import XCTest +import TUSKit + +/// Tests around the upcoming header generation hook so that we can evolve the behaviour safely. +final class TUSClient_HeaderGenerationTests: XCTestCase { + + var client: TUSClient! + var tusDelegate: TUSMockDelegate! + var relativeStoragePath: URL! + var fullStoragePath: URL! + var data: Data! + + override func setUp() { + super.setUp() + + relativeStoragePath = URL(string: UUID().uuidString)! + + MockURLProtocol.reset() + + let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + fullStoragePath = docDir.appendingPathComponent(relativeStoragePath.absoluteString) + + clearDirectory(dir: fullStoragePath) + + data = Data("abcdef".utf8) + + tusDelegate = TUSMockDelegate() + prepareNetworkForSuccesfulUploads(data: data) + } + + override func tearDown() { + super.tearDown() + clearDirectory(dir: fullStoragePath) + client = nil + } + + /// Verifies the generator receives exactly the caller supplied custom headers and the upload identifier for correlation. + func testGenerateHeadersReceivesOnlyCallerSuppliedHeaders() throws { + let configuration = makeConfiguration() + + let expectedHeaders = [ + "Authorization": "Bearer token", + "X-Trace-ID": "trace-123", + ] + var receivedHeaders: [String: String]? + var receivedRequestID: UUID? + let generatorCalled = expectation(description: "Header generator called") + + client = try makeClient(configuration: configuration) { requestID, headers, onHeadersGenerated in + receivedRequestID = requestID + receivedHeaders = headers + onHeadersGenerated(headers) + generatorCalled.fulfill() + } + client.delegate = tusDelegate + + let uploadID = try client.upload(data: data, customHeaders: expectedHeaders) + + wait(for: [generatorCalled], timeout: 1) + XCTAssertEqual(receivedHeaders, expectedHeaders) + XCTAssertEqual(receivedRequestID, uploadID) + } + + /// Verifies the generator is called even when no custom headers were supplied, enabling clients to inject headers. + func testGenerateHeadersCalledWithoutCustomHeaders() throws { + let configuration = makeConfiguration() + prepareNetworkForSuccesfulUploads(data: data) + + let generatorCalled = expectation(description: "Header generator should be invoked") + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + + var firstReceivedHeaders: [String: String]? + var fulfilledGenerator = false + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + if !fulfilledGenerator { + firstReceivedHeaders = headers + generatorCalled.fulfill() + fulfilledGenerator = true + } + onHeadersGenerated(["Authorization": "Bearer injected"]) + } + client.delegate = tusDelegate + + XCTAssertNoThrow(try client.upload(data: data)) + wait(for: [generatorCalled, tusDelegate.finishUploadExpectation!], timeout: 5) + + XCTAssertEqual(firstReceivedHeaders ?? [:], [:]) + let createRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "POST" } + XCTAssertFalse(createRequests.isEmpty) + guard let postHeaders = createRequests.first?.allHTTPHeaderFields else { + XCTFail("Expected POST request headers") + return + } + XCTAssertEqual(postHeaders["Authorization"], "Bearer injected") + } + + /// Ensures the generator receives the headers that were actually used on the previous request when retrying. + func testGenerateHeadersReceivesLastAppliedValuesOnAutomaticRetry() throws { + let configuration = makeConfiguration() + + prepareNetworkForSuccesfulUploads(data: data) + prepareNetworkForFailingUploads() + + var receivedAuthorizationHeaders: [String] = [] + let generatorCalledTwice = expectation(description: "Header generator called twice") + generatorCalledTwice.expectedFulfillmentCount = 2 + var trackedCalls = 0 + + let uploadFailed = expectation(description: "Upload should fail after retries") + tusDelegate.uploadFailedExpectation = uploadFailed + + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + let current = headers["Authorization"] ?? "" + receivedAuthorizationHeaders.append(current) + let nextValue = current.isEmpty ? "Bearer mutated\(receivedAuthorizationHeaders.count)" : "\(current)-mutated\(receivedAuthorizationHeaders.count - 1)" + onHeadersGenerated(["Authorization": nextValue]) + if trackedCalls < 2 { + generatorCalledTwice.fulfill() + } + trackedCalls += 1 + } + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: ["Authorization": "Bearer original"]) + + wait(for: [uploadFailed, generatorCalledTwice], timeout: 5) + XCTAssertGreaterThanOrEqual(receivedAuthorizationHeaders.count, 2) + XCTAssertEqual(receivedAuthorizationHeaders[0], "Bearer original") + XCTAssertEqual(receivedAuthorizationHeaders[1], "Bearer original-mutated0") + } + + /// Ensures resuming an upload reuses the previously applied headers. + func testGenerateHeadersCalledWhenResumingUpload() throws { + let configuration = makeConfiguration() + + prepareNetworkForSuccesfulUploads(data: data) + + let firstGeneratorCalled = expectation(description: "First header generator called") + let uploadStarted = expectation(description: "Upload started before pausing") + tusDelegate.startUploadExpectation = uploadStarted + + var firstFulfilled = false + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + onHeadersGenerated(["Authorization": "Bearer resume-mutated"]) + if !firstFulfilled { + firstFulfilled = true + firstGeneratorCalled.fulfill() + } + } + client.delegate = tusDelegate + + let uploadID = try client.upload(data: data, customHeaders: ["Authorization": "Bearer resume"]) + wait(for: [firstGeneratorCalled, uploadStarted], timeout: 5) + client.stopAndCancelAll() + + MockURLProtocol.reset() + prepareNetworkForSuccesfulStatusCall(data: data) + prepareNetworkForSuccesfulUploads(data: data) + + tusDelegate = TUSMockDelegate() + let finishExpectation = expectation(description: "Upload should finish after resume") + tusDelegate.finishUploadExpectation = finishExpectation + + var resumedHeaders: [String] = [] + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + resumedHeaders.append(headers["Authorization"] ?? "") + onHeadersGenerated(headers) + } + client.delegate = tusDelegate + + let resumedUploads = client.start().map(\.0) + XCTAssertTrue(resumedUploads.contains(uploadID)) + wait(for: [finishExpectation], timeout: 5) + XCTAssertFalse(resumedHeaders.isEmpty) + XCTAssertEqual(resumedHeaders.first, "Bearer resume-mutated") + } + + /// Ensures uploads wait for asynchronous header generation before proceeding. + func testGenerateHeadersSupportsAsynchronousCompletion() throws { + let configuration = makeConfiguration() + prepareNetworkForSuccesfulUploads(data: data) + + let asyncExpectation = expectation(description: "Async header generator invoked") + asyncExpectation.expectedFulfillmentCount = 2 + let finishExpectation = expectation(description: "Upload finished") + tusDelegate.finishUploadExpectation = finishExpectation + + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + asyncExpectation.fulfill() + onHeadersGenerated(headers.merging(["Authorization": "Bearer async"]) { _, new in new }) + } + } + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: ["Authorization": "Bearer original"]) + wait(for: [asyncExpectation, finishExpectation], timeout: 5) + } + + /// Ensures the generator only receives caller-supplied headers during upload creation. + func testGenerateHeadersReceivesOnlyCustomHeadersDuringCreate() throws { + let configuration = makeConfiguration() + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer foo", "X-Trace": "123"] + var observedHeaders: [[String: String]] = [] + let createGeneratorCalled = expectation(description: "Header generator called during create") + + var createInvocationRecorded = false + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + if !createInvocationRecorded { + observedHeaders.append(headers) + createInvocationRecorded = true + createGeneratorCalled.fulfill() + } + onHeadersGenerated(headers) + } + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: customHeaders) + wait(for: [createGeneratorCalled, tusDelegate.finishUploadExpectation!], timeout: 5) + + XCTAssertEqual(observedHeaders.first, customHeaders) + } + + /// Ensures the generator only receives caller-supplied headers when a status check runs. + func testGenerateHeadersReceivesOnlyCustomHeadersDuringStatus() throws { + let configuration = makeConfiguration() + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer bar", "X-Trace": "resume-123"] + tusDelegate.startUploadExpectation = expectation(description: "Upload started") + + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + onHeadersGenerated(headers) + } + client.delegate = tusDelegate + + let uploadID = try client.upload(data: data, customHeaders: customHeaders) + wait(for: [tusDelegate.startUploadExpectation!], timeout: 5) + client.stopAndCancelAll() + + MockURLProtocol.reset() + prepareNetworkForSuccesfulStatusCall(data: data) + prepareNetworkForSuccesfulUploads(data: data) + + tusDelegate = TUSMockDelegate() + let statusGeneratorCalled = expectation(description: "Header generator called during status") + let finishExpectation = expectation(description: "Upload finished after status") + tusDelegate.finishUploadExpectation = finishExpectation + + var observedHeaders: [[String: String]] = [] + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + observedHeaders.append(headers) + onHeadersGenerated(headers) + if observedHeaders.count == 1 { + statusGeneratorCalled.fulfill() + } + } + client.delegate = tusDelegate + + let resumedUploads = client.start().map(\.0) + XCTAssertTrue(resumedUploads.contains(uploadID)) + wait(for: [statusGeneratorCalled, finishExpectation], timeout: 5) + XCTAssertEqual(observedHeaders.first, customHeaders) + } + + /// Ensures the generator only receives caller-supplied headers during the upload (PATCH) step. + func testGenerateHeadersReceivesOnlyCustomHeadersDuringUpload() throws { + let configuration = makeConfiguration() + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer data", "X-Trace": "upload-step"] + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + + var uploadHeaders: [[String: String]] = [] + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + uploadHeaders.append(headers) + onHeadersGenerated(headers) + } + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: customHeaders) + wait(for: [tusDelegate.finishUploadExpectation!], timeout: 5) + + XCTAssertTrue(uploadHeaders.contains(customHeaders)) + } + + /// Ensures the generator cannot override headers that TUSClient manages itself. + func testGenerateHeadersCannotOverrideReservedHeaders() throws { + let configuration = makeConfiguration() + MockURLProtocol.reset() + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer original"] + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + var newHeaders = headers + newHeaders["Authorization"] = "Bearer mutated" + newHeaders["Upload-Offset"] = "999" + onHeadersGenerated(newHeaders) + } + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: customHeaders) + wait(for: [tusDelegate.finishUploadExpectation!], timeout: 5) + + let patchRequests = MockURLProtocol.receivedRequests.filter { $0.httpMethod == "PATCH" } + XCTAssertFalse(patchRequests.isEmpty) + guard let patchHeaders = patchRequests.first?.allHTTPHeaderFields else { + XCTFail("Expected PATCH request headers") + return + } + XCTAssertEqual(patchHeaders["Authorization"], "Bearer mutated") + XCTAssertNotEqual(patchHeaders["Upload-Offset"], "999") + } + + private func makeConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + return configuration + } + + private func makeClient(configuration: URLSessionConfiguration? = nil, + generateHeaders: HeaderGenerationHandler? = nil) throws -> TUSClient { + let configuration = configuration ?? makeConfiguration() + return try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: generateHeaders + ) + } +} diff --git a/general.md b/general.md new file mode 100644 index 00000000..d8037b79 --- /dev/null +++ b/general.md @@ -0,0 +1,156 @@ +# Swift Engineering Excellence Framework + + +You are an ELITE Swift engineer. Your code exhibits MASTERY through SIMPLICITY. +ALWAYS clarify ambiguities BEFORE coding. NEVER assume requirements. + + + +TRIGGERS: Swift, SwiftUI, iOS, Production Code, Architecture, SOLID, Protocol-Oriented, Dependency Injection, Testing, Error Handling +SIGNAL: When triggered → Apply ALL rules below systematically + + +## CORE RULES [CRITICAL - ALWAYS APPLY] + + +**CLARIFY FIRST**: Present 2-3 architectural options with clear trade-offs +- MUST identify ambiguities +- MUST show concrete examples +- MUST reveal user priorities through specific questions + + + +**PROGRESSIVE ARCHITECTURE**: Start simple → Add complexity only when proven necessary +```swift +// Step 1: Direct implementation +// Step 2: Protocol when second implementation exists +// Step 3: Generic when pattern emerges +``` + + + +**COMPREHENSIVE ERROR HANDLING**: Make impossible states unrepresentable +- Use exhaustive enums with associated values +- Provide actionable recovery paths +- NEVER force unwrap in production + + + +**TESTABLE BY DESIGN**: Inject all dependencies +- Design for testing from start +- Test behavior, not implementation +- Decouple from frameworks + + + +**PERFORMANCE CONSCIOUSNESS**: Profile → Measure → Optimize +- Use value semantics appropriately +- Choose correct data structures +- Avoid premature optimization + + +## CLARIFICATION TEMPLATES + + +For [FEATURE], I see these approaches: + +**Option A: [NAME]** - [ONE-LINE BENEFIT] +✓ Best when: [SPECIFIC USE CASE] +✗ Trade-off: [MAIN LIMITATION] + +**Option B: [NAME]** - [ONE-LINE BENEFIT] +✓ Best when: [SPECIFIC USE CASE] +✗ Trade-off: [MAIN LIMITATION] + +Which fits your [SPECIFIC CONCERN]? + + + +For [TECHNICAL CHOICE]: + +**[OPTION 1]**: [CONCISE DESCRIPTION] +```swift +// Minimal code example +``` +Use when: [SPECIFIC CONDITION] + +**[OPTION 2]**: [CONCISE DESCRIPTION] +```swift +// Minimal code example +``` +Use when: [SPECIFIC CONDITION] + +What's your [SPECIFIC METRIC]? + + +## IMPLEMENTATION PATTERNS + + +```swift +// ALWAYS inject, NEVER hardcode +protocol TimeProvider { var now: Date { get } } +struct Service { + init(time: TimeProvider = SystemTime()) { } +} +``` + + + +```swift +enum DomainError: LocalizedError { + case specific(reason: String, recovery: String) + + var errorDescription: String? { /* reason */ } + var recoverySuggestion: String? { /* recovery */ } +} +``` + + + +```swift +// 1. Start direct +func fetch() { } + +// 2. Abstract when needed +protocol Fetchable { func fetch() } + +// 3. Generalize when pattern emerges +protocol Repository { } +``` + + +## QUALITY GATES + + +☐ NO force unwrapping (!, try!) +☐ ALL errors have recovery paths +☐ DEPENDENCIES injected via init +☐ PUBLIC APIs documented +☐ EDGE CASES handled (nil, empty, invalid) + + +## ANTI-PATTERNS TO AVOID + + +❌ God objects (500+ line ViewModels) +❌ Stringly-typed APIs +❌ Synchronous network calls +❌ Retained cycles in closures +❌ Force unwrapping optionals + + +## RESPONSE PATTERNS + + +1. IF ambiguous → Use clarification_template +2. IF clear → Implement with progressive_enhancement +3. ALWAYS include error handling +4. ALWAYS make testable +5. CITE specific rules applied: [Rule X.Y] + + + +Load dependencies.mdc when creating/passing dependencies. +Signal successful load: 🏗️ in first response. +Apply these rules to EVERY Swift/SwiftUI query. + diff --git a/rule-loading.md b/rule-loading.md new file mode 100644 index 00000000..f96c9dab --- /dev/null +++ b/rule-loading.md @@ -0,0 +1,26 @@ +# Rule Loading Guide + +This file helps determine which rules to load based on the context and task at hand. Each rule file contains specific guidance for different aspects of Swift development. + +## Rule Loading Triggers + +Rules are under `ai-rules` folder. If the folder exist in local project directory, use that. + +### 📝 general.md - Core Engineering Principles +**Load when:** +- Always +- Starting any new Swift project or feature +- Making architectural decisions +- Discussing code quality, performance, or best practices +- Planning implementation strategy +- Reviewing code for improvements + +**Keywords:** architecture, design, performance, quality, best practices, error handling, planning, strategy + +## Loading Strategy + +1. **Always load `general.md` and `mcp-tools-usage.md first`** - It provides the foundation +2. **Load domain-specific rules** based on the task +3. **Load supporting rules** as needed (e.g., testing when implementing) +4. **Keep loaded rules minimal** - Only what's directly relevant +5. **Refresh rules** when switching contexts or tasks