diff --git a/.github/workflows/macos-10.15.yml b/.github/workflows/macos-14.yml similarity index 75% rename from .github/workflows/macos-10.15.yml rename to .github/workflows/macos-14.yml index 9297bc4..1a62baf 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,12 @@ on: jobs: test: - runs-on: macos-10.15 + runs-on: macos-14 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint - 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..d5cc92c 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,12 @@ 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: Install SwiftLint + run: brew install swiftlint - name: Lint run: | swiftlint --strict 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 diff --git a/Package.swift b/Package.swift index 9ead1b2..b88a739 100644 --- a/Package.swift +++ b/Package.swift @@ -1,15 +1,21 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.5 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: [], targets: [ .target( name: "FTAPIKit", @@ -17,7 +23,6 @@ let package = Package( ), .testTarget( name: "FTAPIKitTests", - dependencies: ["FTAPIKit"] - ) + dependencies: ["FTAPIKit"]) ] ) diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift deleted file mode 100644 index 200c504..0000000 --- a/Package@swift-5.5.swift +++ /dev/null @@ -1,22 +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(.v12), .macOS(.v10_10), .tvOS(.v12), .watchOS(.v5)], - products: [ - .library( - name: "FTAPIKit", - targets: ["FTAPIKit"]) - ], - targets: [ - .target( - name: "FTAPIKit", - dependencies: []), - .testTarget( - name: "FTAPIKitTests", - dependencies: ["FTAPIKit"]) - ] -) 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/NetworkObserver.swift b/Sources/FTAPIKit/NetworkObserver.swift new file mode 100644 index 0000000..7de99e8 --- /dev/null +++ b/Sources/FTAPIKit/NetworkObserver.swift @@ -0,0 +1,46 @@ +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. +/// +/// ## 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` and optionally `didFail` + func willSendRequest(_ request: URLRequest) -> Context + + /// 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) + /// - 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. + /// + /// Called after `didReceiveResponse` if processing determines the request failed. + /// - Parameters: + /// - request: The original request + /// - 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/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`. diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift index 5888d2e..fcfbcd6 100644 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ b/Sources/FTAPIKit/URLServer+Task.swift @@ -22,8 +22,18 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDataTask? { + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } + let task = urlSession.dataTask(with: request) { data, response, error in - completion(process(data, response, error)) + tokens.forEach { $0.didReceiveResponse(response, data) } + + let result = process(data, response, error) + + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } + } + + completion(result) } task.resume() return task @@ -35,8 +45,18 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask? { + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } + let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - completion(process(data, response, error)) + tokens.forEach { $0.didReceiveResponse(response, data) } + + let result = process(data, response, error) + + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } + } + + completion(result) } task.resume() return task @@ -47,8 +67,18 @@ extension URLServer { process: @escaping (URL?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDownloadTask? { + let tokens = networkObservers.map { RequestToken(observer: $0, request: request) } + let task = urlSession.downloadTask(with: request) { url, response, error in - completion(process(url, response, error)) + tokens.forEach { $0.didReceiveResponse(response, nil) } + + let result = process(url, response, error) + + if case let .failure(apiError) = result { + tokens.forEach { $0.didFail(apiError) } + } + + completion(result) } task.resume() return task @@ -77,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 cd9a96c..4279bc8 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -43,12 +43,18 @@ public protocol URLServer: Server where Request == URLRequest { /// `URLSession` instance, which is used for task execution /// - Note: Provided default implementation. var urlSession: URLSession { 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 networkObservers: [any NetworkObserver] { [] } func buildRequest(endpoint: Endpoint) throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) 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 685dae6..b1fdc7c 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -29,3 +29,13 @@ struct ErrorThrowingServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! } + +struct HTTPBinServerWithObservers: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "http://httpbin.org/")! + let networkObservers: [any NetworkObserver] + + 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..734eda1 --- /dev/null +++ b/Tests/FTAPIKitTests/NetworkObserverTests.swift @@ -0,0 +1,94 @@ +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") + // didReceiveResponse is always called; didFail is called additionally on failure + XCTAssertEqual(mockObserver.didReceiveCount, 1, "didReceiveResponse should always 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) + + // 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() { + 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") + XCTAssertEqual(observer1.didReceiveCount, 1, "Observer 1 didReceiveResponse should be called") + XCTAssertEqual(observer2.didReceiveCount, 1, "Observer 2 didReceiveResponse should be called") + } +}