Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions Sources/FTAPIKit/NetworkObserver.swift
Original file line number Diff line number Diff line change
@@ -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)
}
86 changes: 34 additions & 52 deletions Sources/FTAPIKit/URLServer+Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,17 @@ extension URLServer {
process: @escaping (Data?, URLResponse?, Error?) -> Result<R, ErrorType>,
completion: @escaping (Result<R, ErrorType>) -> 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()
Expand All @@ -57,29 +45,15 @@ extension URLServer {
process: @escaping (Data?, URLResponse?, Error?) -> Result<R, ErrorType>,
completion: @escaping (Result<R, ErrorType>) -> 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)
Expand All @@ -93,28 +67,15 @@ extension URLServer {
process: @escaping (URL?, URLResponse?, Error?) -> Result<URL, ErrorType>,
completion: @escaping (Result<URL, ErrorType>) -> 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)
Expand Down Expand Up @@ -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<T: NetworkObserver>(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)
}
}
}
10 changes: 5 additions & 5 deletions Sources/FTAPIKit/URLServer.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import FTNetworkTracer

#if os(Linux)
import FoundationNetworking
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 0 additions & 30 deletions Tests/FTAPIKitTests/Mockups/Analytics.swift

This file was deleted.

33 changes: 33 additions & 0 deletions Tests/FTAPIKitTests/Mockups/MockNetworkObserver.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
9 changes: 4 additions & 5 deletions Tests/FTAPIKitTests/Mockups/Servers.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import Foundation
import FTAPIKit
import FTNetworkTracer

#if os(Linux)
import FoundationNetworking
Expand Down Expand Up @@ -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
}
}
Loading