From c89e9c11dda24dba081a58da5fd8e13d1f8d4935 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 13:47:14 +0100 Subject: [PATCH 01/16] feat(headers): add header generation hook --- AGENTS.md | 53 +++++++++++ Sources/TUSKit/TUSAPI.swift | 17 ++-- Sources/TUSKit/TUSClient.swift | 57 ++++++++++-- Sources/TUSKit/Tasks/CreationTask.swift | 66 +++++++------ Sources/TUSKit/Tasks/StatusTask.swift | 93 ++++++++++--------- Sources/TUSKit/Tasks/UploadDataTask.swift | 44 ++++++--- Tests/TUSKitTests/TUSAPITests.swift | 8 +- .../TUSClient_HeaderGenerationTests.swift | 71 ++++++++++++++ 8 files changed, 301 insertions(+), 108 deletions(-) create mode 100644 AGENTS.md create mode 100644 Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift 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/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..dd54b5af 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() @@ -516,7 +525,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 +577,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 +636,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 { @@ -766,17 +775,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 +821,35 @@ extension TUSClient: ProgressDelegate { } } +final class HeaderGenerator { + private let handler: HeaderGenerationHandler? + + init(handler: HeaderGenerationHandler?) { + self.handler = handler + } + + func resolveHeaders(for metaData: UploadMetadata, completion: @escaping ([String: String]) -> Void) { + let baseHeaders = metaData.customHeaders ?? [:] + guard let handler = handler else { + completion(baseHeaders) + return + } + + handler(metaData.id, baseHeaders) { headers in + let allowedKeys = Set(baseHeaders.keys) + guard !allowedKeys.isEmpty else { + completion([:]) + return + } + var sanitized = baseHeaders + for (key, value) in headers where allowedKeys.contains(key) { + sanitized[key] = value + } + completion(sanitized) + } + } +} + private extension URL { var mimeType: String { let pathExtension = self.pathExtension @@ -823,4 +861,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/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..84662bd7 --- /dev/null +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -0,0 +1,71 @@ +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: "TUSHeaderTests")! + + 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 = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + + 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { 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) + } +} From c9524cfbd2b3c4bc3b7250dff4edf2477ceb5f71 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 13:53:19 +0100 Subject: [PATCH 02/16] test(headers): ensure generator unused without custom headers --- Sources/TUSKit/TUSClient.swift | 4 +++ .../TUSClient_HeaderGenerationTests.swift | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index dd54b5af..57021539 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -830,6 +830,10 @@ final class HeaderGenerator { func resolveHeaders(for metaData: UploadMetadata, completion: @escaping ([String: String]) -> Void) { let baseHeaders = metaData.customHeaders ?? [:] + guard !baseHeaders.isEmpty else { + completion([:]) + return + } guard let handler = handler else { completion(baseHeaders) return diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 84662bd7..46af67a9 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -68,4 +68,29 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertEqual(receivedHeaders, expectedHeaders) XCTAssertEqual(receivedRequestID, uploadID) } + + /// Verifies we don't bother clients when there are no custom headers to override. + func testGenerateHeadersNotCalledWhenNoCustomHeaders() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + + let generatorNotCalled = expectation(description: "Header generator should not be invoked") + generatorNotCalled.isInverted = true + + client = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, _, onHeadersGenerated in + generatorNotCalled.fulfill() + onHeadersGenerated([:]) + } + ) + client.delegate = tusDelegate + + XCTAssertNoThrow(try client.upload(data: data)) + wait(for: [generatorNotCalled], timeout: 0.5) + } } From 85f29d93579d22fbbe303e0c28cddedd8092d274 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 14:31:02 +0100 Subject: [PATCH 03/16] test(headers): reuse last header values on retry --- Sources/TUSKit/TUSClient.swift | 46 +++++++++++++++++-- .../TUSClient_HeaderGenerationTests.swift | 43 +++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 57021539..60bf018c 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -349,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) } @@ -367,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) @@ -693,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) } @@ -753,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) } @@ -823,35 +827,67 @@ 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 = metaData.customHeaders ?? [:] + let baseHeaders = storedHeaders(for: metaData) guard !baseHeaders.isEmpty else { completion([:]) return } guard let handler = handler else { + store(headers: baseHeaders, for: metaData.id) completion(baseHeaders) return } - handler(metaData.id, baseHeaders) { headers in - let allowedKeys = Set(baseHeaders.keys) - guard !allowedKeys.isEmpty else { - completion([:]) + handler(metaData.id, baseHeaders) { [weak self] headers in + guard let self else { + completion(baseHeaders) return } + let allowedKeys = Set(baseHeaders.keys) var sanitized = baseHeaders for (key, value) in headers where allowedKeys.contains(key) { sanitized[key] = value } + self.store(headers: sanitized, for: metaData.id) 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] { + queue.sync { + if let stored = latestHeaders[metaData.id] { + return stored + } else { + return metaData.customHeaders ?? [:] + } + } + } + + private func store(headers: [String: String], for id: UUID) { + queue.async { + self.latestHeaders[id] = headers + } + } } private extension URL { diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 46af67a9..ed9e047f 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -93,4 +93,47 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertNoThrow(try client.upload(data: data)) wait(for: [generatorNotCalled], timeout: 0.5) } + + /// Ensures the generator receives the headers that were actually used on the previous request when retrying. + func testGenerateHeadersReceivesLastAppliedValuesOnAutomaticRetry() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + + 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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") + } } From e293441ae690b9e4799c9804c868423186eb8bcf Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 14:37:43 +0100 Subject: [PATCH 04/16] test(headers): verify generator invoked on resume --- .../TUSClient_HeaderGenerationTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index ed9e047f..003adfe4 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -136,4 +136,37 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { 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 = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + + prepareNetworkForSuccesfulUploads(data: data) + prepareNetworkForFailingUploads() + + let generatorCalledTwice = expectation(description: "Header generator called twice") + generatorCalledTwice.expectedFulfillmentCount = 2 + var calls: [String] = [] + + client = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, headers, onHeadersGenerated in + let current = headers["Authorization"] ?? "" + calls.append(current) + onHeadersGenerated(["Authorization": current.isEmpty ? "Bearer resuming" : "\(current)-resumed"]) + generatorCalledTwice.fulfill() + } + ) + client.delegate = tusDelegate + + let uploadID = try client.upload(data: data, customHeaders: ["Authorization": "Bearer resume"]) + wait(for: [generatorCalledTwice], timeout: 5) + XCTAssertEqual(calls.first, "Bearer resume") + XCTAssertEqual(calls.last, "Bearer resume-resumed") + } } From 71e2dd8bfc2009ac9da69a8ad67065703617483b Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:10:21 +0100 Subject: [PATCH 05/16] test(headers): persist custom headers across resume --- Sources/TUSKit/TUSClient.swift | 22 ++++---- Sources/TUSKit/UploadMetada.swift | 29 +++++++++-- .../TUSClient_HeaderGenerationTests.swift | 52 ++++++++++++++----- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 60bf018c..8e1e55d8 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -841,13 +841,14 @@ final class HeaderGenerator { return } guard let handler = handler else { - store(headers: baseHeaders, for: metaData.id) + store(headers: baseHeaders, for: metaData) completion(baseHeaders) return } handler(metaData.id, baseHeaders) { [weak self] headers in guard let self else { + metaData.updateAppliedCustomHeaders(baseHeaders) completion(baseHeaders) return } @@ -856,7 +857,7 @@ final class HeaderGenerator { for (key, value) in headers where allowedKeys.contains(key) { sanitized[key] = value } - self.store(headers: sanitized, for: metaData.id) + self.store(headers: sanitized, for: metaData) completion(sanitized) } } @@ -874,18 +875,19 @@ final class HeaderGenerator { } private func storedHeaders(for metaData: UploadMetadata) -> [String: String] { - queue.sync { - if let stored = latestHeaders[metaData.id] { - return stored - } else { - return metaData.customHeaders ?? [:] - } + 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 id: UUID) { + private func store(headers: [String: String], for metaData: UploadMetadata) { + metaData.updateAppliedCustomHeaders(headers) queue.async { - self.latestHeaders[id] = headers + self.latestHeaders[metaData.id] = headers } } } 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/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 003adfe4..ec2b6001 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -13,7 +13,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { override func setUp() { super.setUp() - relativeStoragePath = URL(string: "TUSHeaderTests")! + relativeStoragePath = URL(string: UUID().uuidString)! MockURLProtocol.reset() @@ -143,12 +143,12 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { configuration.protocolClasses = [MockURLProtocol.self] prepareNetworkForSuccesfulUploads(data: data) - prepareNetworkForFailingUploads() - let generatorCalledTwice = expectation(description: "Header generator called twice") - generatorCalledTwice.expectedFulfillmentCount = 2 - var calls: [String] = [] + let firstGeneratorCalled = expectation(description: "First header generator called") + let uploadStarted = expectation(description: "Upload started before pausing") + tusDelegate.startUploadExpectation = uploadStarted + var firstFulfilled = false client = try TUSClient( server: URL(string: "https://tusd.tusdemo.net/files")!, sessionIdentifier: "TEST", @@ -156,17 +156,45 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { storageDirectory: relativeStoragePath, supportedExtensions: [.creation], generateHeaders: { _, headers, onHeadersGenerated in - let current = headers["Authorization"] ?? "" - calls.append(current) - onHeadersGenerated(["Authorization": current.isEmpty ? "Bearer resuming" : "\(current)-resumed"]) - generatorCalledTwice.fulfill() + 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: [generatorCalledTwice], timeout: 5) - XCTAssertEqual(calls.first, "Bearer resume") - XCTAssertEqual(calls.last, "Bearer resume-resumed") + 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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") } } From f97ffb5472be92f393b3de837cdb2226172dff94 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:17:35 +0100 Subject: [PATCH 06/16] test(headers): cover async header generation --- .../TUSClient_HeaderGenerationTests.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index ec2b6001..c0a75561 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -197,4 +197,34 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertFalse(resumedHeaders.isEmpty) XCTAssertEqual(resumedHeaders.first, "Bearer resume-mutated") } + + /// Ensures uploads wait for asynchronous header generation before proceeding. + func testGenerateHeadersSupportsAsynchronousCompletion() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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) + } } From 59f9d0c065dd8cc8f5c20b10d24eadab73adef6e Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:21:42 +0100 Subject: [PATCH 07/16] test(headers): ensure create step only exposes custom headers --- .../TUSClient_HeaderGenerationTests.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index c0a75561..3f54dfb9 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -227,4 +227,39 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { _ = 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 = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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) + } } From 056168a8a685721f2bf5151202ae672ac294b7ef Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:25:55 +0100 Subject: [PATCH 08/16] test(headers): ensure status step uses custom headers --- .../TUSClient_HeaderGenerationTests.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 3f54dfb9..653e7641 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -262,4 +262,61 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertEqual(observedHeaders.first, customHeaders) } + + /// Ensures the generator only receives caller-supplied headers when a status check runs. + func testGenerateHeadersReceivesOnlyCustomHeadersDuringStatus() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer bar", "X-Trace": "resume-123"] + tusDelegate.startUploadExpectation = expectation(description: "Upload started") + + client = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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 TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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) + } } From 98b4b84274fc7c8d068439aa21c922470dcf107f Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:29:03 +0100 Subject: [PATCH 09/16] test(headers): validate upload step headers --- .../TUSClient_HeaderGenerationTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 653e7641..c9b6632f 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -319,4 +319,33 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { 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 = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer data", "X-Trace": "upload-step"] + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + + var uploadHeaders: [[String: String]] = [] + client = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, 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)) + } } From 6d4410e6e14c0382cf2959222f254aa08a9ee7d7 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:30:41 +0100 Subject: [PATCH 10/16] test(headers): reject generator injected headers --- .../TUSClient_HeaderGenerationTests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index c9b6632f..f5bc9913 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -348,4 +348,40 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertTrue(uploadHeaders.contains(customHeaders)) } + + /// Ensures new headers introduced by the generator are ignored for protocol safety. + func testGenerateHeadersDoesNotAllowUnknownHeaders() throws { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + MockURLProtocol.reset() + prepareNetworkForSuccesfulUploads(data: data) + + let customHeaders = ["Authorization": "Bearer original"] + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") + + client = try TUSClient( + server: URL(string: "https://tusd.tusdemo.net/files")!, + sessionIdentifier: "TEST", + sessionConfiguration: configuration, + storageDirectory: relativeStoragePath, + supportedExtensions: [.creation], + generateHeaders: { _, headers, onHeadersGenerated in + var newHeaders = headers + newHeaders["Authorization"] = "Bearer mutated" + newHeaders["X-Injected"] = "should-not-pass" + onHeadersGenerated(newHeaders) + } + ) + client.delegate = tusDelegate + + _ = try client.upload(data: data, customHeaders: customHeaders) + wait(for: [tusDelegate.finishUploadExpectation!], timeout: 5) + + XCTAssertTrue(MockURLProtocol.receivedRequests.contains { request in + request.allHTTPHeaderFields?["Authorization"] == "Bearer mutated" + }) + XCTAssertFalse(MockURLProtocol.receivedRequests.contains { request in + request.allHTTPHeaderFields?["X-Injected"] != nil + }) + } } From 39e0e5b474d57fc9a8f7617427e70dc5f46fa6dc Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:45:03 +0100 Subject: [PATCH 11/16] feat(headers): allow new headers but protect reserved ones --- Sources/TUSKit/TUSClient.swift | 15 +++++++++++--- .../TUSClient_HeaderGenerationTests.swift | 20 ++++++++++--------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 8e1e55d8..080d10a4 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -848,13 +848,13 @@ final class HeaderGenerator { handler(metaData.id, baseHeaders) { [weak self] headers in guard let self else { - metaData.updateAppliedCustomHeaders(baseHeaders) completion(baseHeaders) return } - let allowedKeys = Set(baseHeaders.keys) var sanitized = baseHeaders - for (key, value) in headers where allowedKeys.contains(key) { + 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) @@ -890,6 +890,15 @@ final class HeaderGenerator { 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 { diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index f5bc9913..34f8eb00 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -349,8 +349,8 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertTrue(uploadHeaders.contains(customHeaders)) } - /// Ensures new headers introduced by the generator are ignored for protocol safety. - func testGenerateHeadersDoesNotAllowUnknownHeaders() throws { + /// Ensures the generator cannot override headers that TUSClient manages itself. + func testGenerateHeadersCannotOverrideReservedHeaders() throws { let configuration = URLSessionConfiguration.default configuration.protocolClasses = [MockURLProtocol.self] MockURLProtocol.reset() @@ -368,7 +368,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { generateHeaders: { _, headers, onHeadersGenerated in var newHeaders = headers newHeaders["Authorization"] = "Bearer mutated" - newHeaders["X-Injected"] = "should-not-pass" + newHeaders["Upload-Offset"] = "999" onHeadersGenerated(newHeaders) } ) @@ -377,11 +377,13 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { _ = try client.upload(data: data, customHeaders: customHeaders) wait(for: [tusDelegate.finishUploadExpectation!], timeout: 5) - XCTAssertTrue(MockURLProtocol.receivedRequests.contains { request in - request.allHTTPHeaderFields?["Authorization"] == "Bearer mutated" - }) - XCTAssertFalse(MockURLProtocol.receivedRequests.contains { request in - request.allHTTPHeaderFields?["X-Injected"] != nil - }) + 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") } } From f04169aafdb0fb618c2c9faa77c668c94d649a6d Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:51:51 +0100 Subject: [PATCH 12/16] some AI fixes --- general.md | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ rule-loading.md | 26 ++++++++ 2 files changed, 182 insertions(+) create mode 100644 general.md create mode 100644 rule-loading.md 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 From 0f1c9b4b1599fa210a85a0980a4933bdd1dbd5bc Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 15:58:22 +0100 Subject: [PATCH 13/16] test(headers): standardize client setup in header tests --- .../TUSClient_HeaderGenerationTests.swift | 243 +++++++----------- 1 file changed, 88 insertions(+), 155 deletions(-) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 34f8eb00..2b2858bb 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -36,8 +36,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Verifies the generator receives exactly the caller supplied custom headers and the upload identifier for correlation. func testGenerateHeadersReceivesOnlyCallerSuppliedHeaders() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() let expectedHeaders = [ "Authorization": "Bearer token", @@ -47,19 +46,12 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { var receivedRequestID: UUID? let generatorCalled = expectation(description: "Header generator called") - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { requestID, headers, onHeadersGenerated in - receivedRequestID = requestID - receivedHeaders = headers - onHeadersGenerated(headers) - generatorCalled.fulfill() - } - ) + 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) @@ -71,23 +63,15 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Verifies we don't bother clients when there are no custom headers to override. func testGenerateHeadersNotCalledWhenNoCustomHeaders() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() let generatorNotCalled = expectation(description: "Header generator should not be invoked") generatorNotCalled.isInverted = true - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, _, onHeadersGenerated in - generatorNotCalled.fulfill() - onHeadersGenerated([:]) - } - ) + client = try makeClient(configuration: configuration) { _, _, onHeadersGenerated in + generatorNotCalled.fulfill() + onHeadersGenerated([:]) + } client.delegate = tusDelegate XCTAssertNoThrow(try client.upload(data: data)) @@ -96,8 +80,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures the generator receives the headers that were actually used on the previous request when retrying. func testGenerateHeadersReceivesLastAppliedValuesOnAutomaticRetry() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() prepareNetworkForSuccesfulUploads(data: data) prepareNetworkForFailingUploads() @@ -110,23 +93,16 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { let uploadFailed = expectation(description: "Upload should fail after retries") tusDelegate.uploadFailedExpectation = uploadFailed - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, 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 = 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"]) @@ -139,8 +115,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures resuming an upload reuses the previously applied headers. func testGenerateHeadersCalledWhenResumingUpload() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() prepareNetworkForSuccesfulUploads(data: data) @@ -149,20 +124,13 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { tusDelegate.startUploadExpectation = uploadStarted var firstFulfilled = false - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - onHeadersGenerated(["Authorization": "Bearer resume-mutated"]) - if !firstFulfilled { - firstFulfilled = true - firstGeneratorCalled.fulfill() - } + 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"]) @@ -178,17 +146,10 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { tusDelegate.finishUploadExpectation = finishExpectation var resumedHeaders: [String] = [] - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - resumedHeaders.append(headers["Authorization"] ?? "") - onHeadersGenerated(headers) - } - ) + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + resumedHeaders.append(headers["Authorization"] ?? "") + onHeadersGenerated(headers) + } client.delegate = tusDelegate let resumedUploads = client.start().map(\.0) @@ -200,8 +161,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures uploads wait for asynchronous header generation before proceeding. func testGenerateHeadersSupportsAsynchronousCompletion() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() prepareNetworkForSuccesfulUploads(data: data) let asyncExpectation = expectation(description: "Async header generator invoked") @@ -209,19 +169,12 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { let finishExpectation = expectation(description: "Upload finished") tusDelegate.finishUploadExpectation = finishExpectation - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - asyncExpectation.fulfill() - onHeadersGenerated(headers.merging(["Authorization": "Bearer async"]) { _, new in new }) - } + 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"]) @@ -230,8 +183,7 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures the generator only receives caller-supplied headers during upload creation. func testGenerateHeadersReceivesOnlyCustomHeadersDuringCreate() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() prepareNetworkForSuccesfulUploads(data: data) let customHeaders = ["Authorization": "Bearer foo", "X-Trace": "123"] @@ -239,21 +191,14 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { let createGeneratorCalled = expectation(description: "Header generator called during create") var createInvocationRecorded = false - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - if !createInvocationRecorded { - observedHeaders.append(headers) - createInvocationRecorded = true - createGeneratorCalled.fulfill() - } - onHeadersGenerated(headers) + 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 @@ -265,23 +210,15 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures the generator only receives caller-supplied headers when a status check runs. func testGenerateHeadersReceivesOnlyCustomHeadersDuringStatus() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() prepareNetworkForSuccesfulUploads(data: data) let customHeaders = ["Authorization": "Bearer bar", "X-Trace": "resume-123"] tusDelegate.startUploadExpectation = expectation(description: "Upload started") - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - onHeadersGenerated(headers) - } - ) + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + onHeadersGenerated(headers) + } client.delegate = tusDelegate let uploadID = try client.upload(data: data, customHeaders: customHeaders) @@ -298,20 +235,13 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { tusDelegate.finishUploadExpectation = finishExpectation var observedHeaders: [[String: String]] = [] - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - observedHeaders.append(headers) - onHeadersGenerated(headers) - if observedHeaders.count == 1 { - statusGeneratorCalled.fulfill() - } + 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) @@ -322,25 +252,17 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures the generator only receives caller-supplied headers during the upload (PATCH) step. func testGenerateHeadersReceivesOnlyCustomHeadersDuringUpload() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + 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 TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - uploadHeaders.append(headers) - onHeadersGenerated(headers) - } - ) + client = try makeClient(configuration: configuration) { _, headers, onHeadersGenerated in + uploadHeaders.append(headers) + onHeadersGenerated(headers) + } client.delegate = tusDelegate _ = try client.upload(data: data, customHeaders: customHeaders) @@ -351,27 +273,19 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { /// Ensures the generator cannot override headers that TUSClient manages itself. func testGenerateHeadersCannotOverrideReservedHeaders() throws { - let configuration = URLSessionConfiguration.default - configuration.protocolClasses = [MockURLProtocol.self] + let configuration = makeConfiguration() MockURLProtocol.reset() prepareNetworkForSuccesfulUploads(data: data) let customHeaders = ["Authorization": "Bearer original"] tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") - client = try TUSClient( - server: URL(string: "https://tusd.tusdemo.net/files")!, - sessionIdentifier: "TEST", - sessionConfiguration: configuration, - storageDirectory: relativeStoragePath, - supportedExtensions: [.creation], - generateHeaders: { _, headers, onHeadersGenerated in - var newHeaders = headers - newHeaders["Authorization"] = "Bearer mutated" - newHeaders["Upload-Offset"] = "999" - onHeadersGenerated(newHeaders) - } - ) + 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) @@ -386,4 +300,23 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { 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 + ) + } } From 1c3be33be28714bfa268bd3de1cf52fe02a6281b Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 16:07:31 +0100 Subject: [PATCH 14/16] updated ci config and specify .swift-version --- .github/workflows/tuskit-ci.yml | 2 +- .swift-version | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .swift-version 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 From 42b36faf9292f817a339302be15345024002cd29 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Wed, 19 Nov 2025 16:13:30 +0100 Subject: [PATCH 15/16] Updated the readme --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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. From afa7ab921fdaf94b6ce0325ae7b760e7da2587d4 Mon Sep 17 00:00:00 2001 From: Donny Wals Date: Mon, 24 Nov 2025 20:17:44 +0100 Subject: [PATCH 16/16] fix(headers): always invoke generator without custom headers --- Sources/TUSKit/TUSClient.swift | 4 --- .../TUSClient_HeaderGenerationTests.swift | 32 ++++++++++++++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Sources/TUSKit/TUSClient.swift b/Sources/TUSKit/TUSClient.swift index 080d10a4..b40fb23b 100644 --- a/Sources/TUSKit/TUSClient.swift +++ b/Sources/TUSKit/TUSClient.swift @@ -836,10 +836,6 @@ final class HeaderGenerator { func resolveHeaders(for metaData: UploadMetadata, completion: @escaping ([String: String]) -> Void) { let baseHeaders = storedHeaders(for: metaData) - guard !baseHeaders.isEmpty else { - completion([:]) - return - } guard let handler = handler else { store(headers: baseHeaders, for: metaData) completion(baseHeaders) diff --git a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift index 2b2858bb..5e2bdbc7 100644 --- a/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift +++ b/Tests/TUSKitTests/TUSClient/TUSClient_HeaderGenerationTests.swift @@ -61,21 +61,37 @@ final class TUSClient_HeaderGenerationTests: XCTestCase { XCTAssertEqual(receivedRequestID, uploadID) } - /// Verifies we don't bother clients when there are no custom headers to override. - func testGenerateHeadersNotCalledWhenNoCustomHeaders() throws { + /// 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 generatorNotCalled = expectation(description: "Header generator should not be invoked") - generatorNotCalled.isInverted = true + let generatorCalled = expectation(description: "Header generator should be invoked") + tusDelegate.finishUploadExpectation = expectation(description: "Upload finished") - client = try makeClient(configuration: configuration) { _, _, onHeadersGenerated in - generatorNotCalled.fulfill() - onHeadersGenerated([:]) + 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: [generatorNotCalled], timeout: 0.5) + 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.