diff --git a/Package.swift b/Package.swift index 9ead1b2..9c456de 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/futuredapp/FTNetworkTracer", from: "0.2.1") + ], 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..24cfcb2 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/futuredapp/FTNetworkTracer", from: "0.2.0") + ], 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..bad12dc 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,17 @@ public protocol URLServer: Server where Request == URLRequest { /// `URLSession` instance, which is used for task execution /// - 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 } } 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) 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/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..c9c22c8 --- /dev/null +++ b/Tests/FTAPIKitTests/NetworkTracerIntegrationTests.swift @@ -0,0 +1,75 @@ +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") + } +}