From 044c9bd6df15da96f86d89aac6b88b0cda06c017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 3 Nov 2025 15:00:42 +0100 Subject: [PATCH 01/15] feat(network-tracing): Integrate FTNetworkTracer for request logging and tracking Adds FTNetworkTracer to enable comprehensive logging and tracking of network requests, responses, and errors for URLSession tasks. This improves debugging and observability. Also updates minimum platform versions. --- Package.swift | 17 ++++-- Package@swift-5.5.swift | 15 +++++- Sources/FTAPIKit/URLServer+Task.swift | 75 +++++++++++++++++++++++++-- Sources/FTAPIKit/URLServer.swift | 4 ++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 9ead1b2..785a715 100644 --- a/Package.swift +++ b/Package.swift @@ -4,20 +4,29 @@ import PackageDescription let package = Package( name: "FTAPIKit", - platforms: [.iOS(.v12), .macOS(.v10_10), .tvOS(.v12), .watchOS(.v5)], + platforms: [ + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7) + ], products: [ .library( name: "FTAPIKit", targets: ["FTAPIKit"]) ], + dependencies: [ + .package(url: "https://github.com/ssestak/FTNetworkTracer", branch: "main") + ], targets: [ .target( name: "FTAPIKit", - dependencies: [] + dependencies: [ + .product(name: "FTNetworkTracer", package: "FTNetworkTracer") + ] ), .testTarget( name: "FTAPIKitTests", - dependencies: ["FTAPIKit"] - ) + dependencies: ["FTAPIKit"]) ] ) diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 200c504..106ba84 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -5,16 +5,27 @@ import PackageDescription let package = Package( name: "FTAPIKit", - platforms: [.iOS(.v12), .macOS(.v10_10), .tvOS(.v12), .watchOS(.v5)], + platforms: [ + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7) + ], products: [ .library( name: "FTAPIKit", targets: ["FTAPIKit"]) ], + dependencies: [ + .package(url: "https://github.com/ssestak/FTNetworkTracer", branch: "main") + ], targets: [ .target( name: "FTAPIKit", - dependencies: []), + dependencies: [ + .product(name: "FTNetworkTracer", package: "FTNetworkTracer") + ] + ), .testTarget( name: "FTAPIKitTests", dependencies: ["FTAPIKit"]) diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift index 5888d2e..11df0c5 100644 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ b/Sources/FTAPIKit/URLServer+Task.swift @@ -22,8 +22,30 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDataTask? { + let requestId = UUID().uuidString + let startTime = Date() + + networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.dataTask(with: request) { data, response, error in - completion(process(data, response, error)) + networkTracer?.logAndTrackResponse( + request: request, + response: response, + data: data, + requestId: requestId, + startTime: startTime + ) + + let result = process(data, response, error) + + if case let .failure(error) = result { + networkTracer?.logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + completion(result) } task.resume() return task @@ -35,8 +57,32 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask? { + let requestId = UUID().uuidString + let startTime = Date() + + networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - completion(process(data, response, error)) + networkTracer?.logAndTrackResponse( + request: request, + response: response, + data: data, + requestId: requestId, + startTime: startTime + ) + + let result = process(data, response, error) + + // Log and track error if any + if case let .failure(error) = result { + networkTracer?.logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + + completion(result) } task.resume() return task @@ -47,8 +93,31 @@ extension URLServer { process: @escaping (URL?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDownloadTask? { + let requestId = UUID().uuidString + let startTime = Date() + + networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.downloadTask(with: request) { url, response, error in - completion(process(url, response, error)) + networkTracer?.logAndTrackResponse( + request: request, + response: response, + data: nil, + requestId: requestId, + startTime: startTime + ) + + let result = process(url, response, error) + + if case let .failure(error) = result { + networkTracer?.logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + + completion(result) } task.resume() return task diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index cd9a96c..a37b116 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -1,4 +1,5 @@ import Foundation +import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -43,12 +44,15 @@ public protocol URLServer: Server where Request == URLRequest { /// `URLSession` instance, which is used for task execution /// - Note: Provided default implementation. var urlSession: URLSession { get } + + var networkTracer: FTNetworkTracer? { get } } public extension URLServer { var urlSession: URLSession { .shared } var decoding: Decoding { JSONDecoding() } var encoding: Encoding { JSONEncoding() } + var networkTracer: FTNetworkTracer? { nil } func buildRequest(endpoint: Endpoint) throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) From 2001b9ca1080beb0d59aee2b33d379dffeb678de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 4 Nov 2025 16:02:17 +0100 Subject: [PATCH 02/15] test(network-tracer): add integration tests for FTNetworkTracer This commit introduces a new test suite to verify the proper integration and functionality of FTNetworkTracer with FTAPIKit, ensuring network events are correctly logged. --- Tests/FTAPIKitTests/Mockups/Servers.swift | 11 ++ .../NetworkTracerIntegrationTests.swift | 105 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 685dae6..29f7956 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,5 +1,6 @@ import Foundation import FTAPIKit +import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -29,3 +30,13 @@ struct ErrorThrowingServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! } + +struct HTTPBinServerWithTracer: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + let networkTracer: FTNetworkTracer? + + init(tracer: FTNetworkTracer?) { + self.networkTracer = tracer + } +} diff --git a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift new file mode 100644 index 0000000..80f845b --- /dev/null +++ b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift @@ -0,0 +1,105 @@ +import FTAPIKit +import FTNetworkTracer +import XCTest + +#if os(Linux) +import FoundationNetworking +#endif + +final class NetworkTracerIntegrationTests: XCTestCase { + private let timeout: TimeInterval = 30.0 + + // MARK: - Unit Tests (no network required) + + func testTracerIsCalledForRequest() { + let mockAnalytics = MockAnalytics() + let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) + let server = HTTPBinServerWithTracer(tracer: tracer) + let endpoint = GetEndpoint() + + // Build request to verify tracer integration + _ = try? server.buildRequest(endpoint: endpoint) + + // Note: Just building request doesn't trigger logging, + // but this verifies the tracer property is properly integrated + XCTAssertNotNil(server.networkTracer, "NetworkTracer should be set") + } + + func testNilTracerDoesNotCauseIssues() { + let server = HTTPBinServer() // Default tracer is nil + let endpoint = GetEndpoint() + + // Verify nil tracer doesn't cause problems during request building + XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) + XCTAssertNil(server.networkTracer, "Default networkTracer should be nil") + } + + func testMockAnalyticsTracking() { + let mockAnalytics = MockAnalytics() + let analyticEntry = AnalyticEntry( + type: .request(method: "GET", url: "https://test.com"), + headers: [:], + body: nil, + duration: nil, + requestId: "test-123", + configuration: mockAnalytics.configuration + ) + + mockAnalytics.track(analyticEntry) + + XCTAssertEqual(mockAnalytics.requestCount, 1) + XCTAssertEqual(mockAnalytics.lastRequestId, "test-123") + } + + // MARK: - Integration Tests (requires network) + // Note: These tests may fail if httpbin.org is unavailable + + func testTracerLogsFailedRequest() { + let mockAnalytics = MockAnalytics() + let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) + let server = HTTPBinServerWithTracer(tracer: tracer) + let endpoint = NotFoundEndpoint() + let expectation = self.expectation(description: "Result") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + // Verify tracer was called (request is always logged, even on failure) + XCTAssertEqual(mockAnalytics.requestCount, 1, "Request should be logged once") + XCTAssertGreaterThanOrEqual(mockAnalytics.responseCount + mockAnalytics.errorCount, 1, + "Either response or error should be logged") + } +} + +// MARK: - Mock Analytics + +private class MockAnalytics: AnalyticsProtocol { + var requestCount = 0 + var responseCount = 0 + var errorCount = 0 + var lastRequestId: String? + var lastDuration: TimeInterval? + + let configuration: AnalyticsConfiguration = AnalyticsConfiguration( + privacy: .none, + unmaskedHeaders: [], + unmaskedUrlQueries: [], + unmaskedBodyParams: [] + ) + + func track(_ entry: AnalyticEntry) { + switch entry.type { + case .request: + requestCount += 1 + case .response: + responseCount += 1 + case .error: + errorCount += 1 + } + lastRequestId = entry.requestId + lastDuration = entry.duration + } +} From 06583504d45d923f6dd2919b8c5bb6eee73cb6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 12 Nov 2025 13:19:51 +0100 Subject: [PATCH 03/15] chore: Update FTNetworkTracer dependency URL Migrate FTNetworkTracer dependency from personal repository to futuredapp organization. --- Package.swift | 2 +- Package@swift-5.5.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 785a715..0037c85 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/ssestak/FTNetworkTracer", branch: "main") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", branch: "main") ], targets: [ .target( diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 106ba84..0085533 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -17,7 +17,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/ssestak/FTNetworkTracer", branch: "main") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", branch: "main") ], targets: [ .target( From 12a3d85da6964ad29f239c7f60f11526833a06b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 12 Nov 2025 13:24:18 +0100 Subject: [PATCH 04/15] refactor: Move MockAnalytics to its own file Extract MockAnalytics class from NetworkTracerIntegrationTests into a separate file for better organization and potential reusability across tests. --- Tests/FTAPIKitTests/Mockups/Analytics.swift | 30 +++++++++++++++++++ .../NetworkTracerIntegrationTests.swift | 30 ------------------- 2 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 Tests/FTAPIKitTests/Mockups/Analytics.swift diff --git a/Tests/FTAPIKitTests/Mockups/Analytics.swift b/Tests/FTAPIKitTests/Mockups/Analytics.swift new file mode 100644 index 0000000..295193d --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/Analytics.swift @@ -0,0 +1,30 @@ +import Foundation +import FTNetworkTracer + +class MockAnalytics: AnalyticsProtocol { + var requestCount = 0 + var responseCount = 0 + var errorCount = 0 + var lastRequestId: String? + var lastDuration: TimeInterval? + + let configuration: AnalyticsConfiguration = AnalyticsConfiguration( + privacy: .none, + unmaskedHeaders: [], + unmaskedUrlQueries: [], + unmaskedBodyParams: [] + ) + + func track(_ entry: AnalyticEntry) { + switch entry.type { + case .request: + requestCount += 1 + case .response: + responseCount += 1 + case .error: + errorCount += 1 + } + lastRequestId = entry.requestId + lastDuration = entry.duration + } +} diff --git a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift index 80f845b..c9c22c8 100644 --- a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift +++ b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift @@ -73,33 +73,3 @@ final class NetworkTracerIntegrationTests: XCTestCase { "Either response or error should be logged") } } - -// MARK: - Mock Analytics - -private class MockAnalytics: AnalyticsProtocol { - var requestCount = 0 - var responseCount = 0 - var errorCount = 0 - var lastRequestId: String? - var lastDuration: TimeInterval? - - let configuration: AnalyticsConfiguration = AnalyticsConfiguration( - privacy: .none, - unmaskedHeaders: [], - unmaskedUrlQueries: [], - unmaskedBodyParams: [] - ) - - func track(_ entry: AnalyticEntry) { - switch entry.type { - case .request: - requestCount += 1 - case .response: - responseCount += 1 - case .error: - errorCount += 1 - } - lastRequestId = entry.requestId - lastDuration = entry.duration - } -} From 2a4ac310f0fbd630837045216ce7c8867212786e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 12 Nov 2025 13:25:08 +0100 Subject: [PATCH 05/15] docs: add documentation for networkTracer property Added documentation to clarify the purpose and default implementation of the `networkTracer` property in the `URLServer` protocol. --- Sources/FTAPIKit/URLServer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index a37b116..bad12dc 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -45,6 +45,8 @@ public protocol URLServer: Server where Request == URLRequest { /// - Note: Provided default implementation. var urlSession: URLSession { get } + /// Optional network tracer for request logging and tracking + /// - Note: Provided default implementation returns nil. var networkTracer: FTNetworkTracer? { get } } From 1dfea4ed67b615352a55fcd5c6d7f2dbb287bb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 12 Nov 2025 14:17:43 +0100 Subject: [PATCH 06/15] chore: Update FTNetworkTracer dependency to 0.1.0 Depend on a specific version instead of the main branch for stability. --- Package.swift | 2 +- Package@swift-5.5.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 0037c85..8506306 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", branch: "main") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.1.0") ], targets: [ .target( diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 0085533..be7c636 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -17,7 +17,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", branch: "main") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.1.0") ], targets: [ .target( From 9da0a9734ea74f2b7f92e0b9c4e6eb4d9469a18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 13 Nov 2025 10:58:59 +0100 Subject: [PATCH 07/15] chore: Update FTNetworkTracer to 0.2.0 --- Package.swift | 2 +- Package@swift-5.5.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 8506306..781e441 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.1.0") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.0") ], targets: [ .target( diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index be7c636..24cfcb2 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -17,7 +17,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.1.0") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.0") ], targets: [ .target( From 8e2610bc41266232c7c7a38e167a65f0445cf18a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 7 Jan 2026 15:06:02 +0100 Subject: [PATCH 08/15] chore(deps): Update FTNetworkTracer to 0.2.1 Updates the FTNetworkTracer dependency to version 0.2.1. --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 781e441..9c456de 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( targets: ["FTAPIKit"]) ], dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.0") + .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.1") ], targets: [ .target( From 7f9e6292df5a8b218a023e954f7c3dc639634e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 7 Jan 2026 15:25:25 +0100 Subject: [PATCH 09/15] ci: Update macOS GitHub Actions workflows to newer versions Migrate workflows to use macOS 14 and 15 runners, update actions/checkout to v4, and remove redundant Xcode setup. --- .github/workflows/{macos-10.15.yml => macos-14.yml} | 6 +++--- .github/workflows/{macos-11.yml => macos-15.yml} | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) rename .github/workflows/{macos-10.15.yml => macos-14.yml} (84%) rename .github/workflows/{macos-11.yml => macos-15.yml} (68%) diff --git a/.github/workflows/macos-10.15.yml b/.github/workflows/macos-14.yml similarity index 84% rename from .github/workflows/macos-10.15.yml rename to .github/workflows/macos-14.yml index 9297bc4..5602b3e 100644 --- a/.github/workflows/macos-10.15.yml +++ b/.github/workflows/macos-14.yml @@ -1,4 +1,4 @@ -name: macOS 10.15 +name: macOS 14 on: push: @@ -10,10 +10,10 @@ on: jobs: test: - runs-on: macos-10.15 + runs-on: macos-14 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Lint run: | swiftlint --strict diff --git a/.github/workflows/macos-11.yml b/.github/workflows/macos-15.yml similarity index 68% rename from .github/workflows/macos-11.yml rename to .github/workflows/macos-15.yml index e29fc66..b814c45 100644 --- a/.github/workflows/macos-11.yml +++ b/.github/workflows/macos-15.yml @@ -1,4 +1,4 @@ -name: macOS 11 +name: macOS 15 on: push: @@ -10,14 +10,10 @@ on: jobs: test: - runs-on: macos-11 + runs-on: macos-15 steps: - - uses: actions/checkout@v2 - - name: Setup Xcode version - uses: maxim-lobanov/setup-xcode@v1.4.0 - with: - xcode-version: 13.1 + - uses: actions/checkout@v4 - name: Lint run: | swiftlint --strict From dbdfbe9d7ce345fde333d35a4d3ddd3655d6c3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 7 Jan 2026 15:26:43 +0100 Subject: [PATCH 10/15] chore: Unify Swift Package Manager configuration The project can now use a single Package.swift for both building and DocC generation by updating the tools version to 5.5. --- Package.swift | 2 +- Package@swift-5.5.swift | 33 --------------------------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 Package@swift-5.5.swift diff --git a/Package.swift b/Package.swift index 9c456de..fb466b5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.5 import PackageDescription diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift deleted file mode 100644 index 24cfcb2..0000000 --- a/Package@swift-5.5.swift +++ /dev/null @@ -1,33 +0,0 @@ -// swift-tools-version:5.5 -// This manifest is needed to properly generate DocC documentation. - -import PackageDescription - -let package = Package( - name: "FTAPIKit", - platforms: [ - .iOS(.v14), - .macOS(.v11), - .tvOS(.v14), - .watchOS(.v7) - ], - products: [ - .library( - name: "FTAPIKit", - targets: ["FTAPIKit"]) - ], - dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.0") - ], - targets: [ - .target( - name: "FTAPIKit", - dependencies: [ - .product(name: "FTNetworkTracer", package: "FTNetworkTracer") - ] - ), - .testTarget( - name: "FTAPIKitTests", - dependencies: ["FTAPIKit"]) - ] -) From c0f4980c611aa5265eb67b37c9833149e1546158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Wed, 7 Jan 2026 17:48:15 +0100 Subject: [PATCH 11/15] refactor: Replace FTNetworkTracer with generic NetworkObserver middleware - Remove hard dependency on FTNetworkTracer package - Introduce NetworkObserver protocol with type-safe associated Context - Support array of middlewares for multiple observers - Zero overhead when no observers configured (empty array = no work) - Use RequestToken pattern for type erasure while preserving type safety Breaking change: `networkTracer: FTNetworkTracer?` replaced with `networkObservers: [any NetworkObserver]` --- Package.swift | 8 +- Sources/FTAPIKit/NetworkObserver.swift | 34 ++++++ Sources/FTAPIKit/URLServer+Task.swift | 86 ++++++--------- Sources/FTAPIKit/URLServer.swift | 10 +- Tests/FTAPIKitTests/Mockups/Analytics.swift | 30 ------ .../Mockups/MockNetworkObserver.swift | 33 ++++++ Tests/FTAPIKitTests/Mockups/Servers.swift | 9 +- .../FTAPIKitTests/NetworkObserverTests.swift | 100 ++++++++++++++++++ .../NetworkTracerIntegrationTests.swift | 75 ------------- 9 files changed, 212 insertions(+), 173 deletions(-) create mode 100644 Sources/FTAPIKit/NetworkObserver.swift delete mode 100644 Tests/FTAPIKitTests/Mockups/Analytics.swift create mode 100644 Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift create mode 100644 Tests/FTAPIKitTests/NetworkObserverTests.swift delete mode 100644 Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift diff --git a/Package.swift b/Package.swift index fb466b5..b88a739 100644 --- a/Package.swift +++ b/Package.swift @@ -15,15 +15,11 @@ let package = Package( name: "FTAPIKit", targets: ["FTAPIKit"]) ], - dependencies: [ - .package(url: "https://github.com/futuredapp/FTNetworkTracer", from: "0.2.1") - ], + dependencies: [], targets: [ .target( name: "FTAPIKit", - dependencies: [ - .product(name: "FTNetworkTracer", package: "FTNetworkTracer") - ] + dependencies: [] ), .testTarget( name: "FTAPIKitTests", diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift new file mode 100644 index 0000000..49b1599 --- /dev/null +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -0,0 +1,34 @@ +import Foundation + +#if os(Linux) +import FoundationNetworking +#endif + +/// Protocol for observing network request lifecycle events. +/// +/// Implement this protocol to add logging, analytics, or request tracking. +/// The `context` parameter allows passing correlation data (request ID, start time, etc.) +/// between `willSendRequest` and the completion callbacks. +public protocol NetworkObserver: AnyObject, Sendable { + associatedtype Context: Sendable + + /// Called immediately before a request is sent. + /// - Parameter request: The URLRequest about to be sent + /// - Returns: Context to be passed to `didReceiveResponse` or `didFail` + func willSendRequest(_ request: URLRequest) -> Context + + /// Called when a response is received. + /// - Parameters: + /// - request: The original request + /// - response: The URL response (may be HTTPURLResponse) + /// - data: Response body data, if any (nil for download tasks) + /// - context: Value returned from `willSendRequest` + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) + + /// Called when a request fails with an error. + /// - Parameters: + /// - request: The original request + /// - error: The error that occurred + /// - context: Value returned from `willSendRequest` + func didFail(request: URLRequest, error: Error, context: Context) +} diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift index 11df0c5..fcfbcd6 100644 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ b/Sources/FTAPIKit/URLServer+Task.swift @@ -22,29 +22,17 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDataTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.dataTask(with: request) { data, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: data, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, data) } let result = process(data, response, error) - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } + completion(result) } task.resume() @@ -57,29 +45,15 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: data, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, data) } let result = process(data, response, error) - // Log and track error if any - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } completion(result) @@ -93,28 +67,15 @@ extension URLServer { process: @escaping (URL?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDownloadTask? { - let requestId = UUID().uuidString - let startTime = Date() - - networkTracer?.logAndTrackRequest(request: request, requestId: requestId) + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } let task = urlSession.downloadTask(with: request) { url, response, error in - networkTracer?.logAndTrackResponse( - request: request, - response: response, - data: nil, - requestId: requestId, - startTime: startTime - ) + tokens.forEach { $0.didReceiveResponse(response, nil) } let result = process(url, response, error) - if case let .failure(error) = result { - networkTracer?.logAndTrackError( - request: request, - error: error, - requestId: requestId - ) + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } } completion(result) @@ -146,3 +107,24 @@ extension URLServer { return .failure(error) } } + +// This hides the specific 'Context' type inside closures. +private struct RequestToken: Sendable { + let didReceiveResponse: @Sendable (URLResponse?, Data?) -> Void + let didFail: @Sendable (Error) -> Void + + // The generic 'T' captures the specific observer type and its associated Context + init(observer: T, request: URLRequest) { + // We generate the context immediately upon initialization + let context = observer.willSendRequest(request) + + // We capture the specific 'observer' and 'context' inside these closures + self.didReceiveResponse = { [weak observer] response, data in + observer?.didReceiveResponse(for: request, response: response, data: data, context: context) + } + + self.didFail = { [weak observer] error in + observer?.didFail(request: request, error: error, context: context) + } + } +} diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index bad12dc..4279bc8 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -1,5 +1,4 @@ import Foundation -import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -45,16 +44,17 @@ public protocol URLServer: Server where Request == URLRequest { /// - Note: Provided default implementation. var urlSession: URLSession { get } - /// Optional network tracer for request logging and tracking - /// - Note: Provided default implementation returns nil. - var networkTracer: FTNetworkTracer? { get } + /// Array of network observers. + /// Each observer receives lifecycle callbacks for every request. + /// - Note: Provided default implementation returns empty array. + var networkObservers: [any NetworkObserver] { get } } public extension URLServer { var urlSession: URLSession { .shared } var decoding: Decoding { JSONDecoding() } var encoding: Encoding { JSONEncoding() } - var networkTracer: FTNetworkTracer? { nil } + var networkObservers: [any NetworkObserver] { [] } func buildRequest(endpoint: Endpoint) throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) diff --git a/Tests/FTAPIKitTests/Mockups/Analytics.swift b/Tests/FTAPIKitTests/Mockups/Analytics.swift deleted file mode 100644 index 295193d..0000000 --- a/Tests/FTAPIKitTests/Mockups/Analytics.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import FTNetworkTracer - -class MockAnalytics: AnalyticsProtocol { - var requestCount = 0 - var responseCount = 0 - var errorCount = 0 - var lastRequestId: String? - var lastDuration: TimeInterval? - - let configuration: AnalyticsConfiguration = AnalyticsConfiguration( - privacy: .none, - unmaskedHeaders: [], - unmaskedUrlQueries: [], - unmaskedBodyParams: [] - ) - - func track(_ entry: AnalyticEntry) { - switch entry.type { - case .request: - requestCount += 1 - case .response: - responseCount += 1 - case .error: - errorCount += 1 - } - lastRequestId = entry.requestId - lastDuration = entry.duration - } -} diff --git a/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift new file mode 100644 index 0000000..bf3a948 --- /dev/null +++ b/Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift @@ -0,0 +1,33 @@ +import Foundation +import FTAPIKit + +#if os(Linux) +import FoundationNetworking +#endif + +struct MockContext: Sendable { + let requestId: String + let startTime: Date +} + +final class MockNetworkObserver: NetworkObserver, @unchecked Sendable { + var willSendCount = 0 + var didReceiveCount = 0 + var didFailCount = 0 + var lastRequestId: String? + + func willSendRequest(_ request: URLRequest) -> MockContext { + willSendCount += 1 + let context = MockContext(requestId: UUID().uuidString, startTime: Date()) + lastRequestId = context.requestId + return context + } + + func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: MockContext) { + didReceiveCount += 1 + } + + func didFail(request: URLRequest, error: Error, context: MockContext) { + didFailCount += 1 + } +} diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 29f7956..b1fdc7c 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -1,6 +1,5 @@ import Foundation import FTAPIKit -import FTNetworkTracer #if os(Linux) import FoundationNetworking @@ -31,12 +30,12 @@ struct ErrorThrowingServer: URLServer { let baseUri = URL(string: "http://httpbin.org/")! } -struct HTTPBinServerWithTracer: URLServer { +struct HTTPBinServerWithObservers: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! - let networkTracer: FTNetworkTracer? + let networkObservers: [any NetworkObserver] - init(tracer: FTNetworkTracer?) { - self.networkTracer = tracer + init(observers: [any NetworkObserver] = []) { + self.networkObservers = observers } } diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift new file mode 100644 index 0000000..fe4a04c --- /dev/null +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -0,0 +1,100 @@ +import FTAPIKit +import XCTest + +#if os(Linux) +import FoundationNetworking +#endif + +final class NetworkObserverTests: XCTestCase { + private let timeout: TimeInterval = 30.0 + + // MARK: - Unit Tests (no network required) + + func testObserverIsCalledForRequest() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + + XCTAssertEqual(server.networkObservers.count, 1, "NetworkObservers should contain one observer") + } + + func testEmptyObserversDoesNotCauseIssues() { + let server = HTTPBinServer() // Default observers is empty array + let endpoint = GetEndpoint() + + // Verify empty observers doesn't cause problems during request building + XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) + XCTAssertTrue(server.networkObservers.isEmpty, "Default networkObservers should be empty") + } + + func testMultipleObserversSupported() { + let observer1 = MockNetworkObserver() + let observer2 = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) + + XCTAssertEqual(server.networkObservers.count, 2, "Should support multiple observers") + } + + // MARK: - Integration Tests (requires network) + // Note: These tests may fail if httpbin.org is unavailable + + func testObserverReceivesLifecycleCallbacks() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + let endpoint = GetEndpoint() + let expectation = self.expectation(description: "Request completed") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") + XCTAssertGreaterThanOrEqual( + mockObserver.didReceiveCount + mockObserver.didFailCount, + 1, + "Either didReceive or didFail should be called" + ) + } + + func testObserverLogsFailedRequest() { + let mockObserver = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [mockObserver]) + let endpoint = NotFoundEndpoint() + let expectation = self.expectation(description: "Result") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + // Verify observer was called (request is always logged, even on failure) + XCTAssertEqual(mockObserver.willSendCount, 1, "Request should be logged once") + XCTAssertGreaterThanOrEqual( + mockObserver.didReceiveCount + mockObserver.didFailCount, + 1, + "Either response or error should be logged" + ) + } + + func testMultipleObserversAllReceiveCallbacks() { + let observer1 = MockNetworkObserver() + let observer2 = MockNetworkObserver() + let server = HTTPBinServerWithObservers(observers: [observer1, observer2]) + let endpoint = GetEndpoint() + let expectation = self.expectation(description: "Request completed") + + server.call(endpoint: endpoint) { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + // Both observers should receive callbacks + XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") + XCTAssertEqual(observer2.willSendCount, 1, "Observer 2 willSendRequest should be called") + XCTAssertGreaterThanOrEqual(observer1.didReceiveCount + observer1.didFailCount, 1) + XCTAssertGreaterThanOrEqual(observer2.didReceiveCount + observer2.didFailCount, 1) + } +} diff --git a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift deleted file mode 100644 index c9c22c8..0000000 --- a/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import FTAPIKit -import FTNetworkTracer -import XCTest - -#if os(Linux) -import FoundationNetworking -#endif - -final class NetworkTracerIntegrationTests: XCTestCase { - private let timeout: TimeInterval = 30.0 - - // MARK: - Unit Tests (no network required) - - func testTracerIsCalledForRequest() { - let mockAnalytics = MockAnalytics() - let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) - let server = HTTPBinServerWithTracer(tracer: tracer) - let endpoint = GetEndpoint() - - // Build request to verify tracer integration - _ = try? server.buildRequest(endpoint: endpoint) - - // Note: Just building request doesn't trigger logging, - // but this verifies the tracer property is properly integrated - XCTAssertNotNil(server.networkTracer, "NetworkTracer should be set") - } - - func testNilTracerDoesNotCauseIssues() { - let server = HTTPBinServer() // Default tracer is nil - let endpoint = GetEndpoint() - - // Verify nil tracer doesn't cause problems during request building - XCTAssertNoThrow(try server.buildRequest(endpoint: endpoint)) - XCTAssertNil(server.networkTracer, "Default networkTracer should be nil") - } - - func testMockAnalyticsTracking() { - let mockAnalytics = MockAnalytics() - let analyticEntry = AnalyticEntry( - type: .request(method: "GET", url: "https://test.com"), - headers: [:], - body: nil, - duration: nil, - requestId: "test-123", - configuration: mockAnalytics.configuration - ) - - mockAnalytics.track(analyticEntry) - - XCTAssertEqual(mockAnalytics.requestCount, 1) - XCTAssertEqual(mockAnalytics.lastRequestId, "test-123") - } - - // MARK: - Integration Tests (requires network) - // Note: These tests may fail if httpbin.org is unavailable - - func testTracerLogsFailedRequest() { - let mockAnalytics = MockAnalytics() - let tracer = FTNetworkTracer(logger: nil, analytics: mockAnalytics) - let server = HTTPBinServerWithTracer(tracer: tracer) - let endpoint = NotFoundEndpoint() - let expectation = self.expectation(description: "Result") - - server.call(endpoint: endpoint) { _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: timeout) - - // Verify tracer was called (request is always logged, even on failure) - XCTAssertEqual(mockAnalytics.requestCount, 1, "Request should be logged once") - XCTAssertGreaterThanOrEqual(mockAnalytics.responseCount + mockAnalytics.errorCount, 1, - "Either response or error should be logged") - } -} From 87b0bb23615169da97cf0829ea3180f799d69421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 8 Jan 2026 08:57:04 +0100 Subject: [PATCH 12/15] refactor(networkobserver): Clarify callback lifecycle Explicitly document that `didReceiveResponse` is always invoked with raw data, and `didFail` is an additional callback for errors occurring after the response is received. Update tests to assert this behavior. --- Sources/FTAPIKit/NetworkObserver.swift | 22 ++++++++++++++----- .../FTAPIKitTests/NetworkObserverTests.swift | 22 +++++++------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/Sources/FTAPIKit/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift index 49b1599..7de99e8 100644 --- a/Sources/FTAPIKit/NetworkObserver.swift +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -7,17 +7,27 @@ import FoundationNetworking /// Protocol for observing network request lifecycle events. /// /// Implement this protocol to add logging, analytics, or request tracking. -/// The `context` parameter allows passing correlation data (request ID, start time, etc.) -/// between `willSendRequest` and the completion callbacks. +/// +/// ## Context Lifecycle +/// The `Context` associated type allows passing correlation data (request ID, start time, etc.) +/// through the request lifecycle: +/// 1. `willSendRequest` is called before the request starts and returns a `Context` value +/// 2. `didReceiveResponse` is always called with the raw response data (useful for debugging) +/// 3. `didFail` is called additionally if the request processing fails (network, HTTP status, or decoding error) +/// 4. If the observer is deallocated before the request completes, the context is discarded +/// and no completion callback is invoked public protocol NetworkObserver: AnyObject, Sendable { associatedtype Context: Sendable /// Called immediately before a request is sent. /// - Parameter request: The URLRequest about to be sent - /// - Returns: Context to be passed to `didReceiveResponse` or `didFail` + /// - Returns: Context to be passed to `didReceiveResponse` and optionally `didFail` func willSendRequest(_ request: URLRequest) -> Context - /// Called when a response is received. + /// Called when a response is received from the server. + /// + /// This is always called with the raw response data, even if processing subsequently fails. + /// This allows observers to inspect the actual response for debugging purposes. /// - Parameters: /// - request: The original request /// - response: The URL response (may be HTTPURLResponse) @@ -26,9 +36,11 @@ public protocol NetworkObserver: AnyObject, Sendable { func didReceiveResponse(for request: URLRequest, response: URLResponse?, data: Data?, context: Context) /// Called when a request fails with an error. + /// + /// Called after `didReceiveResponse` if processing determines the request failed. /// - Parameters: /// - request: The original request - /// - error: The error that occurred + /// - error: The error that occurred (may be network, HTTP status, or decoding error) /// - context: Value returned from `willSendRequest` func didFail(request: URLRequest, error: Error, context: Context) } diff --git a/Tests/FTAPIKitTests/NetworkObserverTests.swift b/Tests/FTAPIKitTests/NetworkObserverTests.swift index fe4a04c..734eda1 100644 --- a/Tests/FTAPIKitTests/NetworkObserverTests.swift +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -50,11 +50,8 @@ final class NetworkObserverTests: XCTestCase { wait(for: [expectation], timeout: timeout) XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") - XCTAssertGreaterThanOrEqual( - mockObserver.didReceiveCount + mockObserver.didFailCount, - 1, - "Either didReceive or didFail should be called" - ) + // didReceiveResponse is always called; didFail is called additionally on failure + XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") } func testObserverLogsFailedRequest() { @@ -69,13 +66,10 @@ final class NetworkObserverTests: XCTestCase { wait(for: [expectation], timeout: timeout) - // Verify observer was called (request is always logged, even on failure) - XCTAssertEqual(mockObserver.willSendCount, 1, "Request should be logged once") - XCTAssertGreaterThanOrEqual( - mockObserver.didReceiveCount + mockObserver.didFailCount, - 1, - "Either response or error should be logged" - ) + // didReceiveResponse is always called with raw data; didFail is called additionally on failure + XCTAssertEqual(mockObserver.willSendCount, 1, "willSendRequest should be called once") + XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always be called") + XCTAssertEqual(mockObserver.didFailCount, 1, "didFail should be called on failure") } func testMultipleObserversAllReceiveCallbacks() { @@ -94,7 +88,7 @@ final class NetworkObserverTests: XCTestCase { // Both observers should receive callbacks XCTAssertEqual(observer1.willSendCount, 1, "Observer 1 willSendRequest should be called") XCTAssertEqual(observer2.willSendCount, 1, "Observer 2 willSendRequest should be called") - XCTAssertGreaterThanOrEqual(observer1.didReceiveCount + observer1.didFailCount, 1) - XCTAssertGreaterThanOrEqual(observer2.didReceiveCount + observer2.didFailCount, 1) + XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") + XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") } } From 9ee16ff39713c236cdb2e401710d9e0a9444abc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 26 Jan 2026 11:33:48 +0100 Subject: [PATCH 13/15] ci: Install SwiftLint in GitHub Actions workflows SwiftLint is not pre-installed on GitHub Actions macOS runners, causing the lint step to fail with "command not found". Co-Authored-By: Claude Opus 4.5 --- .github/workflows/macos-14.yml | 2 ++ .github/workflows/macos-15.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/macos-14.yml b/.github/workflows/macos-14.yml index 5602b3e..1a62baf 100644 --- a/.github/workflows/macos-14.yml +++ b/.github/workflows/macos-14.yml @@ -14,6 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint - name: Lint run: | swiftlint --strict diff --git a/.github/workflows/macos-15.yml b/.github/workflows/macos-15.yml index b814c45..d5cc92c 100644 --- a/.github/workflows/macos-15.yml +++ b/.github/workflows/macos-15.yml @@ -14,6 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint - name: Lint run: | swiftlint --strict From 3efb5799162a4ddc318242d4521ce275bc479da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 26 Jan 2026 11:45:40 +0100 Subject: [PATCH 14/15] fix: Resolve SwiftLint violations - Use scoped disable/enable for nesting rule in EndpointPublisher - Remove unneeded synthesized initializer in URLRequestBuilder - Fix opening brace spacing in URL+MIME Co-Authored-By: Claude Opus 4.5 --- Sources/FTAPIKit/Combine/EndpointPublisher.swift | 3 ++- Sources/FTAPIKit/URL+MIME.swift | 6 ++---- Sources/FTAPIKit/URLRequestBuilder.swift | 5 ----- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Sources/FTAPIKit/Combine/EndpointPublisher.swift b/Sources/FTAPIKit/Combine/EndpointPublisher.swift index 02af89e..540a9bc 100644 --- a/Sources/FTAPIKit/Combine/EndpointPublisher.swift +++ b/Sources/FTAPIKit/Combine/EndpointPublisher.swift @@ -1,10 +1,10 @@ -// swiftlint:disable nesting import Foundation #if canImport(Combine) import Combine @available(iOS 13.0, tvOS 13.0, watchOS 6.0, macOS 10.15, *) extension Publishers { + // swiftlint:disable nesting struct Endpoint: Publisher { typealias Output = R typealias Failure = E @@ -18,6 +18,7 @@ extension Publishers { subscriber.receive(subscription: subscription) } } + // swiftlint:enable nesting } #endif diff --git a/Sources/FTAPIKit/URL+MIME.swift b/Sources/FTAPIKit/URL+MIME.swift index cc0f1bc..28a5004 100644 --- a/Sources/FTAPIKit/URL+MIME.swift +++ b/Sources/FTAPIKit/URL+MIME.swift @@ -30,10 +30,8 @@ extension URL { #if canImport(CoreServices) private func coreServicesMimeType(for fileExtension: String) -> String? { - if - let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeRetainedValue(), - let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue() - { + if let id = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileExtension as CFString, nil)?.takeRetainedValue(), + let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)?.takeRetainedValue() { return contentType as String } return nil diff --git a/Sources/FTAPIKit/URLRequestBuilder.swift b/Sources/FTAPIKit/URLRequestBuilder.swift index 0032a9b..fe16938 100644 --- a/Sources/FTAPIKit/URLRequestBuilder.swift +++ b/Sources/FTAPIKit/URLRequestBuilder.swift @@ -16,11 +16,6 @@ struct URLRequestBuilder { let server: S let endpoint: Endpoint - init(server: S, endpoint: Endpoint) { - self.server = server - self.endpoint = endpoint - } - /// Creates an instance of `URLRequest` corresponding to provided endpoint executed on provided server. /// It is safe to execute this method multiple times per instance lifetime. /// - Returns: A valid `URLRequest`. From 3596d43abe9f2ca472c4a1901d11db1276f0597c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 26 Jan 2026 11:59:21 +0100 Subject: [PATCH 15/15] fix: Update CocoaPods and deployment targets for Xcode compatibility - Update CocoaPods from ~> 1.11 to ~> 1.14 to fix DT_TOOLCHAIN_DIR error - Bump iOS deployment target from 9.0 to 12.0 - Bump macOS deployment target from 10.10 to 10.13 Co-Authored-By: Claude Opus 4.5 --- FTAPIKit.podspec | 4 +-- Gemfile | 2 +- Gemfile.lock | 87 ++++++++++++++++++++++++++++-------------------- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/FTAPIKit.podspec b/FTAPIKit.podspec index 4b87e7b..a940814 100644 --- a/FTAPIKit.podspec +++ b/FTAPIKit.podspec @@ -21,8 +21,8 @@ Pod::Spec.new do |s| s.weak_frameworks = ["Combine"] s.swift_version = "5.1" - s.ios.deployment_target = "9.0" - s.osx.deployment_target = "10.10" + s.ios.deployment_target = "12.0" + s.osx.deployment_target = "10.13" s.watchos.deployment_target = "5.0" s.tvos.deployment_target = "12.0" end diff --git a/Gemfile b/Gemfile index 597dd9a..c268956 100644 --- a/Gemfile +++ b/Gemfile @@ -2,4 +2,4 @@ source "https://rubygems.org" -gem "cocoapods", "~> 1.11" +gem "cocoapods", "~> 1.14" diff --git a/Gemfile.lock b/Gemfile.lock index d1335e1..d16c54b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,30 +1,38 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.4) - rexml - activesupport (6.1.4.1) - concurrent-ruby (~> 1.0, >= 1.0.2) + CFPropertyList (3.0.8) + activesupport (7.2.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) atomos (0.1.3) - claide (1.0.3) - cocoapods (1.11.2) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.0.1) + claide (1.1.0) + cocoapods (1.16.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.16.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) @@ -32,10 +40,10 @@ GEM gh_inspector (~> 1.0) molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (>= 1.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) - activesupport (>= 5.0, < 7) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.27.0, < 2.0) + cocoapods-core (1.16.2) + activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -45,7 +53,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.6.3) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -54,44 +62,51 @@ GEM netrc (~> 0.11) cocoapods-try (1.2.0) colored2 (3.1.2) - concurrent-ruby (1.1.9) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + drb (2.2.3) escape (0.0.4) ethon (0.15.0) ffi (>= 1.15.0) - ffi (1.15.4) + ffi (1.17.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.8.10) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.6.1) - minitest (5.14.4) + json (2.18.0) + logger (1.7.0) + minitest (6.0.1) + prism (~> 1.5) molinillo (0.8.0) - nanaimo (0.3.0) + mutex_m (0.3.0) + nanaimo (0.4.0) nap (1.1.0) netrc (0.11.0) - public_suffix (4.0.6) - rexml (3.2.5) + prism (1.8.0) + public_suffix (4.0.7) + rexml (3.4.4) ruby-macho (2.5.1) - typhoeus (1.4.0) - ethon (>= 0.9.0) - tzinfo (2.0.4) + securerandom (0.4.1) + typhoeus (1.5.0) + ethon (>= 0.9.0, < 0.16.0) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.21.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - zeitwerk (2.5.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.11) + cocoapods (~> 1.14) BUNDLED WITH - 2.2.29 + 4.0.4