diff --git a/README.md b/README.md index ccbe252..ee61dc2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ Declarative and generic REST API framework using Codable. With standard implementation using URLSesssion and JSON encoder/decoder. Easily extensible for your asynchronous framework or networking stack. +## Documentation + +For detailed API documentation, see the inline documentation in the source code or use Xcode's Quick Help (⌥⌘?) to view comprehensive documentation for each component. + +Key components: +- **Logging**: ``LogEntry``, ``LoggerConfiguration``, ``LogPrivacy`` - Automatic network logging with OSLog +- **Analytics**: ``AnalyticsProtocol``, ``AnalyticEntry``, ``AnalyticsConfiguration`` - Privacy-aware analytics tracking + ## Installation When using Swift package manager install using Xcode 11+ @@ -63,6 +71,15 @@ are separated in various protocols for convenience. ![Endpoint types](Sources/FTAPIKit/Documentation.docc/Resources/Endpoints.svg) +## Logging & Analytics + +FTAPIKit includes comprehensive logging and analytics capabilities: + +- **Logging**: Automatic network request/response logging using native `OSLog` with configurable privacy levels +- **Analytics**: Privacy-aware analytics tracking with automatic data masking for sensitive information + +Both systems work together seamlessly and can be used independently or in combination. + ## Usage ### Defining web service (server) @@ -137,9 +154,9 @@ let server = HTTPBinServer() let endpoint = UpdateUserEndpoint(request: user) server.call(response: endpoint) { result in switch result { - case .success(let updatedUser): + case let .success(updatedUser): ... - case .failure(let error): + case let .failure(error): ... } } diff --git a/Sources/FTAPIKit/Analytics/AnalyticEntry.swift b/Sources/FTAPIKit/Analytics/AnalyticEntry.swift new file mode 100644 index 0000000..ed37b4f --- /dev/null +++ b/Sources/FTAPIKit/Analytics/AnalyticEntry.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Data structure for analytics tracking. +/// +/// This struct contains network activity data that has been privacy-masked based on +/// the configured ``AnalyticsConfiguration``. It uses ``EntryType`` with associated values +/// to provide type-safe access to basic network information without optionals. +/// +/// - Note: This struct is used by ``AnalyticsProtocol`` implementations for tracking +/// network activity. For logging purposes, use ``LogEntry`` instead. +public struct AnalyticEntry { + public let type: EntryType + public let headers: [String: String]? + public let body: Data? + public let timestamp: Date + public let duration: TimeInterval? + public let requestId: String + + public init( + type: EntryType, + headers: [String: String]? = nil, + body: Data? = nil, + timestamp: Date = Date(), + duration: TimeInterval? = nil, + requestId: String = UUID().uuidString, + configuration: AnalyticsConfiguration = AnalyticsConfiguration.default + ) { + // Create masked type with masked URL + let maskedType: EntryType + switch type { + case let .request(method, url): + maskedType = .request(method: method, url: configuration.maskUrl(url) ?? url) + case let .response(method, url, statusCode): + maskedType = .response(method: method, url: configuration.maskUrl(url) ?? url, statusCode: statusCode) + case let .error(method, url, error): + maskedType = .error(method: method, url: configuration.maskUrl(url) ?? url, error: error) + } + + self.type = maskedType + self.headers = configuration.maskHeaders(headers) + self.body = configuration.maskBody(body) + self.timestamp = timestamp + self.duration = duration + self.requestId = requestId + } + + /// Convenience computed properties for accessing associated values + public var method: String { + switch type { + case let .request(method, _), let .response(method, _, _), let .error(method, _, _): + method + } + } + + public var url: String { + switch type { + case let .request(_, url), let .response(_, url, _), let .error(_, url, _): + url + } + } + + public var statusCode: Int? { + switch type { + case let .response(_, _, statusCode): + statusCode + case .request, .error: + nil + } + } + + public var error: String? { + switch type { + case let .error(_, _, error): + error + case .request, .response: + nil + } + } +} diff --git a/Sources/FTAPIKit/Analytics/AnalyticsConfiguration.swift b/Sources/FTAPIKit/Analytics/AnalyticsConfiguration.swift new file mode 100644 index 0000000..381fdad --- /dev/null +++ b/Sources/FTAPIKit/Analytics/AnalyticsConfiguration.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Configuration for analytics functionality. +/// +/// This struct defines the privacy level and exceptions for masking sensitive data +/// in analytics. It allows you to specify which headers, URL query parameters, +/// and body parameters should not be masked. +public struct AnalyticsConfiguration { + private let privacy: AnalyticsPrivacy + private let unmaskedHeaders: Set + private let unmaskedUrlQueries: Set + private let unmaskedBodyParams: Set + + /// Initializes a new analytics configuration. + /// + /// - Parameters: + /// - privacy: The privacy level for data masking. + /// - unmaskedHeaders: A set of header keys that should not be masked. + /// - unmaskedUrlQueries: A set of URL query parameter keys that should not be masked. + /// - unmaskedBodyParams: A set of body parameter keys that should not be masked. + public init( + privacy: AnalyticsPrivacy, + unmaskedHeaders: Set = [], + unmaskedUrlQueries: Set = [], + unmaskedBodyParams: Set = [] + ) { + self.privacy = privacy + self.unmaskedHeaders = unmaskedHeaders + self.unmaskedUrlQueries = unmaskedUrlQueries + self.unmaskedBodyParams = unmaskedBodyParams + } + + /// Default analytics configuration with sensitive privacy + public static let `default` = AnalyticsConfiguration(privacy: .sensitive) + + + // MARK: - Public Masking Methods + + public func maskUrl(_ url: String?) -> String? { + guard let url = url else { return nil } + + switch privacy { + case .none: + return url + case .private: + return maskPrivateUrlQueries(url) + case .sensitive: + return maskSensitiveUrlQueries(url) + } + } + + private func maskPrivateUrlQueries(_ url: String) -> String { + guard let urlComponents = URLComponents(string: url), + let queryItems = urlComponents.queryItems else { return url } + + let maskedQueryItems = queryItems.map { item -> URLQueryItem in + if unmaskedUrlQueries.contains(item.name.lowercased()) { + return item + } + return URLQueryItem(name: item.name, value: "***") + } + + var maskedComponents = urlComponents + maskedComponents.queryItems = maskedQueryItems + return maskedComponents.url?.absoluteString ?? url + } + + private func maskSensitiveUrlQueries(_ url: String) -> String { + guard let urlComponents = URLComponents(string: url) else { return url } + + var maskedComponents = urlComponents + maskedComponents.query = nil + + return maskedComponents.url?.absoluteString ?? url + } + + public func maskHeaders(_ headers: [String: String]?) -> [String: String]? { + guard let headers = headers else { return nil } + + switch privacy { + case .none: + return headers + case .private: + var maskedHeaders: [String: String] = [:] + for (key, value) in headers { + if unmaskedHeaders.contains(key.lowercased()) { + maskedHeaders[key] = value + } else { + maskedHeaders[key] = "***" + } + } + return maskedHeaders + case .sensitive: + return headers.mapValues { _ in "***" } + } + } + + public func maskBody(_ body: Data?) -> Data? { + guard let body = body else { return nil } + + switch privacy { + case .none: + return body + case .private: + return maskPrivateBodyParams(body) + case .sensitive: + return nil + } + } + + private func maskPrivateBodyParams(_ body: Data) -> Data? { + guard let json = try? JSONSerialization.jsonObject(with: body) else { + return "***".data(using: .utf8) + } + + let maskedJson = recursivelyMask(json) + + return try? JSONSerialization.data(withJSONObject: maskedJson) + } + + private func recursivelyMask(_ data: Any) -> Any { + if let dictionary = data as? [String: Any] { + var newDict: [String: Any] = [:] + for (key, value) in dictionary { + if unmaskedBodyParams.contains(key.lowercased()) { + newDict[key] = value + } else { + newDict[key] = recursivelyMask(value) + } + } + return newDict + } else if let array = data as? [Any] { + return array.map { recursivelyMask($0) } + } else { + return "***" + } + } +} \ No newline at end of file diff --git a/Sources/FTAPIKit/Analytics/AnalyticsPrivacy.swift b/Sources/FTAPIKit/Analytics/AnalyticsPrivacy.swift new file mode 100644 index 0000000..25d3e0b --- /dev/null +++ b/Sources/FTAPIKit/Analytics/AnalyticsPrivacy.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Privacy levels for analytics data masking. +/// +/// This enum defines the different levels of privacy for analytics data. +/// Each level determines how much information is masked before being sent +/// to the analytics service. +public enum AnalyticsPrivacy { + /// No privacy masking - all data is preserved. + /// This should be used only for development and debugging. + case none + + /// Private masking - sensitive data in headers, URL queries and body is masked. + /// Unmasked exceptions can be specified in ``AnalyticsConfiguration``. + case `private` + + /// Sensitive masking - all user-specific data is masked. + /// This is the recommended setting for production environments. + case sensitive +} \ No newline at end of file diff --git a/Sources/FTAPIKit/Analytics/AnalyticsProtocol.swift b/Sources/FTAPIKit/Analytics/AnalyticsProtocol.swift new file mode 100644 index 0000000..0fffc56 --- /dev/null +++ b/Sources/FTAPIKit/Analytics/AnalyticsProtocol.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Protocol for analytics functionality. +/// +/// This protocol defines the interface for tracking network requests, responses, and errors +/// for analytics purposes. It provides privacy-aware data tracking with automatic masking +/// of sensitive information. +/// +/// - Note: The ``AnalyticEntry`` passed to the `track` method contains privacy-masked data +/// based on the configured privacy level and sensitive data sets. +public protocol AnalyticsProtocol { + /// Configuration for analytics privacy and masking. + /// + /// This configuration determines how sensitive data is masked before being sent + /// to the analytics service. + var configuration: AnalyticsConfiguration { get } + + /// Tracks an analytic entry for analytics. + /// + /// This method is called automatically by ``URLServer`` implementations + /// for all network requests, responses, and errors. The entry contains + /// privacy-masked data based on the configuration. + /// + /// - Parameter entry: The analytic entry containing network activity data + func track(_ entry: AnalyticEntry) +} diff --git a/Sources/FTAPIKit/Documentation.docc/Documentation.md b/Sources/FTAPIKit/Documentation.docc/Documentation.md index e16a7ce..637f057 100644 --- a/Sources/FTAPIKit/Documentation.docc/Documentation.md +++ b/Sources/FTAPIKit/Documentation.docc/Documentation.md @@ -47,3 +47,16 @@ Easily extensible for your asynchronous framework or networking stack. - ``APIError`` - ``APIErrorStandard`` + +### Logging + +- ``LogEntry`` +- ``LoggerConfiguration`` +- ``LogPrivacy`` + +### Analytics + +- ``AnalyticsProtocol`` +- ``AnalyticEntry`` +- ``AnalyticsConfiguration`` +- ``AnalyticsPrivacy`` diff --git a/Sources/FTAPIKit/EntryType.swift b/Sources/FTAPIKit/EntryType.swift new file mode 100644 index 0000000..1ae02e5 --- /dev/null +++ b/Sources/FTAPIKit/EntryType.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Represents the type of network entry with associated data. +/// +/// This enum uses associated values to provide type-safe access to network entry data, +/// eliminating the need for optionals for basic information like method, URL, and status code. +/// +/// - Note: This enum is used by both ``LogEntry`` and ``AnalyticEntry`` for consistent +/// type-safe data representation across logging and analytics systems. +public enum EntryType { + /// Represents a network request entry. + /// - Parameters: + /// - method: The HTTP method (e.g., "GET", "POST", "PUT") + /// - url: The request URL + case request(method: String, url: String) + + /// Represents a network response entry. + /// - Parameters: + /// - method: The HTTP method that was used + /// - url: The request URL that was called + /// - statusCode: The HTTP status code returned + case response(method: String, url: String, statusCode: Int) + + /// Represents a network error entry. + /// - Parameters: + /// - method: The HTTP method that was attempted + /// - url: The request URL that failed + /// - error: The error message describing what went wrong + case error(method: String, url: String, error: String) + + /// The raw string representation for backwards compatibility. + /// + /// This property provides a string representation of the entry type that can be used + /// for serialization, logging, or analytics tracking. + public var rawValue: String { + switch self { + case .request: + "request" + case .response: + "response" + case .error: + "error" + } + } +} diff --git a/Sources/FTAPIKit/Logger/LogEntry.swift b/Sources/FTAPIKit/Logger/LogEntry.swift new file mode 100644 index 0000000..8bfc804 --- /dev/null +++ b/Sources/FTAPIKit/Logger/LogEntry.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Represents a log entry for logging network activity. +/// +/// This struct contains all the data needed to log network requests, responses, and errors. +/// It uses ``EntryType`` with associated values to provide type-safe access to basic +/// network information without optionals. +/// +/// - Note: For analytics tracking, use ``AnalyticEntry`` instead. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +struct LogEntry { + let type: EntryType + let headers: [String: String]? + let body: Data? + let timestamp: Date + let duration: TimeInterval? + let requestId: String + + init( + type: EntryType, + headers: [String: String]? = nil, + body: Data? = nil, + timestamp: Date = Date(), + duration: TimeInterval? = nil, + requestId: String = UUID().uuidString + ) { + self.type = type + self.headers = headers + self.body = body + self.timestamp = timestamp + self.duration = duration + self.requestId = requestId + } + + /// Convenience computed properties for accessing associated values + var method: String { + switch type { + case let .request(method, _), let .response(method, _, _), let .error(method, _, _): + method + } + } + + var url: String { + switch type { + case let .request(_, url), let .response(_, url, _), let .error(_, url, _): + url + } + } + + var statusCode: Int? { + switch type { + case let .response(_, _, statusCode): + statusCode + case .request, .error: + nil + } + } + + var error: String? { + switch type { + case let .error(_, _, error): + error + case .request, .response: + nil + } + } + + + /// Builds a formatted log message from this LogEntry + func buildMessage(configuration: LoggerConfiguration) -> String { + let requestIdPrefix = String(requestId.prefix(8)) + let timestampString = formatTimestamp(timestamp) + + switch type { + case let .request(method, url): + var message = "[REQUEST] [\(requestIdPrefix)]" + + // Collect all titles for alignment calculation + var allTitles = ["Method", "URL", "Timestamp"] + if let headers, !headers.isEmpty { + allTitles.append(contentsOf: headers.keys) + } + + let maxTitleLength = allTitles.map { $0.count }.max() ?? 0 + message += format(title: "Method", text: method, maxTitleLength: maxTitleLength) + message += format(title: "URL", text: url, maxTitleLength: maxTitleLength) + message += format(title: "Timestamp", text: timestampString, maxTitleLength: maxTitleLength) + + if let headers, !headers.isEmpty { + message += format(headers: headers, maxTitleLength: maxTitleLength) + } + + if let body, let bodyString = configuration.dataDecoder(body) { + message += "\n\tBody:\n \(bodyString)" + } + + return message + + case let .response(method, url, statusCode): + var message = "[RESPONSE] [\(requestIdPrefix)]" + + // Collect all titles for alignment calculation + var allTitles = ["Method", "URL", "Status Code", "Timestamp"] + if duration != nil { + allTitles.append("Duration") + } + if let headers, !headers.isEmpty { + allTitles.append(contentsOf: headers.keys) + } + + let maxTitleLength = allTitles.map { $0.count }.max() ?? 0 + message += format(title: "Method", text: method, maxTitleLength: maxTitleLength) + message += format(title: "URL", text: url, maxTitleLength: maxTitleLength) + message += format(title: "Status Code", text: "\(statusCode)", maxTitleLength: maxTitleLength) + message += format(title: "Timestamp", text: timestampString, maxTitleLength: maxTitleLength) + + if let duration { + message += format(title: "Duration", text: "\(String(format: "%.2f", duration * 1000))ms", maxTitleLength: maxTitleLength) + } + + if let headers, !headers.isEmpty { + message += format(headers: headers, maxTitleLength: maxTitleLength) + } + + if let body, let bodyString = configuration.dataDecoder(body) { + message += "\nBody:\n \(bodyString)" + } + + return message + + case let .error(method, url, error): + var message = "[ERROR] [\(requestIdPrefix)]" + + // Collect all titles for alignment calculation + var allTitles = ["Method", "URL", "ERROR", "Timestamp"] + if let headers, !headers.isEmpty { + allTitles.append(contentsOf: headers.keys) + } + + let maxTitleLength = allTitles.map { $0.count }.max() ?? 0 + message += format(title: "Method", text: method, maxTitleLength: maxTitleLength) + message += format(title: "URL", text: url, maxTitleLength: maxTitleLength) + message += format(title: "ERROR", text: error, maxTitleLength: maxTitleLength) + message += format(title: "Timestamp", text: timestampString, maxTitleLength: maxTitleLength) + + if let body, let bodyString = configuration.dataDecoder(body) { + message += "\nData: \(bodyString)" + } + + return message + } + } + + private func format(headers: [String: String], maxTitleLength: Int) -> String { + guard !headers.isEmpty else { + return "" + } + + var message = "\nHeaders:" + // Sort headers by key to ensure consistent ordering + let sortedHeaders = headers.sorted { $0.key < $1.key } + for (key, value) in sortedHeaders { + message += format(title: key, text: value, maxTitleLength: maxTitleLength) + } + return message + } + + private func format(title: String, text: String, maxTitleLength: Int) -> String { + let padding = String(repeating: " ", count: max(1, maxTitleLength - title.count)) + return "\n\t\(title)\(padding)\(text)" + } + + private func formatTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } +} diff --git a/Sources/FTAPIKit/Logger/LogPrivacy.swift b/Sources/FTAPIKit/Logger/LogPrivacy.swift new file mode 100644 index 0000000..24607f3 --- /dev/null +++ b/Sources/FTAPIKit/Logger/LogPrivacy.swift @@ -0,0 +1,26 @@ +import Foundation +import os.log + +/// Privacy level for logging sensitive data using `OSLogPrivacy`. +/// +/// This enum defines the different levels of privacy for logging data. +/// Each level corresponds to a specific `OSLogPrivacy` level. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +public enum LogPrivacy: String, CaseIterable { + /// Logs all data without any masking (not recommended for production). + /// Corresponds to `OSLogPrivacy.public`. + case none = "none" + + /// Uses `OSLogPrivacy.auto` for automatic privacy detection. + case auto = "auto" + + /// Uses `OSLogPrivacy.private` for sensitive data. + case `private` = "private" + + /// Uses `OSLogPrivacy.sensitive` for highly sensitive data. + case sensitive = "sensitive" + + /// Default privacy level that respects user privacy. + /// The default value is `.auto`. + public static let `default`: LogPrivacy = .auto +} diff --git a/Sources/FTAPIKit/Logger/LoggerConfiguration.swift b/Sources/FTAPIKit/Logger/LoggerConfiguration.swift new file mode 100644 index 0000000..e74f9f6 --- /dev/null +++ b/Sources/FTAPIKit/Logger/LoggerConfiguration.swift @@ -0,0 +1,69 @@ +import Foundation + +#if canImport(os.log) +import os.log +#endif + +/// Configuration for the network logger. +/// +/// This struct defines the configuration for the network logger, including the +/// subsystem and category for `OSLog`, the privacy level for logging, and a +/// custom data decoder for formatting body data. +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +public struct LoggerConfiguration { + let subsystem: String + let category: String + let privacy: LogPrivacy + let dataDecoder: (Data) -> String? + + #if canImport(os.log) + let logger: os.Logger + #endif + + /// Initializes a new logger configuration. + /// + /// - Parameters: + /// - subsystem: The subsystem for `OSLog`. + /// - category: The category for `OSLog`. + /// - privacy: The privacy level for logging. + /// - dataDecoder: A closure that decodes `Data` into a `String` for logging. + public init( + subsystem: String = "com.ftapikit.networking", + category: String = "networking", + privacy: LogPrivacy = .default, + dataDecoder: @escaping (Data) -> String? = LoggerConfiguration.defaultDataDecoder + ) { + self.subsystem = subsystem + self.category = category + self.privacy = privacy + self.dataDecoder = dataDecoder + + #if canImport(os.log) + self.logger = os.Logger(subsystem: subsystem, category: category) + #endif + } + + /// Default data decoder that tries to format as pretty JSON with UTF8 fallback + public static func defaultDataDecoder(_ data: Data) -> String? { + // Try to decode as JSON and pretty print it + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]), + let prettyJSON = String(data: prettyData, encoding: .utf8) { + return prettyJSON + } + + // Fallback to UTF8 string + return String(data: data, encoding: .utf8) + } + + /// Simple UTF8 decoder (no JSON formatting) + public static func utf8DataDecoder(_ data: Data) -> String? { + String(data: data, encoding: .utf8) + } + + /// Custom decoder that only shows data size + public static func sizeOnlyDataDecoder(_ data: Data) -> String? { + "<\(data.count) bytes>" + } + +} diff --git a/Sources/FTAPIKit/URLServer+Task.swift b/Sources/FTAPIKit/URLServer+Task.swift index 5888d2e..b10ecd9 100644 --- a/Sources/FTAPIKit/URLServer+Task.swift +++ b/Sources/FTAPIKit/URLServer+Task.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(os.log) +import os.log +#endif + #if os(Linux) import FoundationNetworking #endif @@ -22,8 +26,34 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDataTask? { + let requestId = UUID().uuidString + let startTime = Date() + + // Log and track request + logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.dataTask(with: request) { data, response, error in - completion(process(data, response, error)) + // Log and track response + 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 { + logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + + completion(result) } task.resume() return task @@ -35,8 +65,34 @@ extension URLServer { process: @escaping (Data?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask? { + let requestId = UUID().uuidString + let startTime = Date() + + // Log and track request + logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.uploadTask(with: request, fromFile: file) { data, response, error in - completion(process(data, response, error)) + // Log and track response + 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 { + logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + + completion(result) } task.resume() return task @@ -47,8 +103,34 @@ extension URLServer { process: @escaping (URL?, URLResponse?, Error?) -> Result, completion: @escaping (Result) -> Void ) -> URLSessionDownloadTask? { + let requestId = UUID().uuidString + let startTime = Date() + + // Log and track request + logAndTrackRequest(request: request, requestId: requestId) + let task = urlSession.downloadTask(with: request) { url, response, error in - completion(process(url, response, error)) + // Log and track response + logAndTrackResponse( + request: request, + response: response, + data: nil, + requestId: requestId, + startTime: startTime + ) + + let result = process(url, response, error) + + // Log and track error if any + if case let .failure(error) = result { + logAndTrackError( + request: request, + error: error, + requestId: requestId + ) + } + + completion(result) } task.resume() return task @@ -76,4 +158,143 @@ extension URLServer { let error = ErrorType(data: nil, response: nil, error: error, decoding: decoding) ?? .unhandled return .failure(error) } + + // MARK: - Private Helpers + + private func logAndTrack( + type: String, + request: URLRequest, + response: HTTPURLResponse? = nil, + data: Data? = nil, + error: Error? = nil, + requestId: String, + startTime: Date? = nil + ) { + let method = request.httpMethod ?? "UNKNOWN" + let url = request.url?.absoluteString ?? "UNKNOWN" + let headers = response?.allHeaderFields as? [String: String] ?? request.allHTTPHeaderFields + let body = data ?? request.httpBody + let statusCode = response?.statusCode + let duration = startTime.map { Date().timeIntervalSince($0) } + let errorString = error.map { String(describing: $0) } + + // Log if logger is available + if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { + if let logger = logger { + let logEntryType: EntryType + switch type { + case "request": + logEntryType = .request(method: method, url: url) + case "response": + logEntryType = .response(method: method, url: url, statusCode: statusCode ?? 0) + case "error": + logEntryType = .error(method: method, url: url, error: errorString ?? "Unknown error") + default: + logEntryType = .request(method: method, url: url) + } + + let logEntry = LogEntry( + type: logEntryType, + headers: headers, + body: body, + duration: duration, + requestId: requestId + ) + + #if canImport(os.log) + // Log to OSLog with proper privacy + let level: OSLogType = { + switch logEntry.type { + case .error: + .error + case let .response(_, _, statusCode): + statusCode >= 400 ? .error : .info + case .request: + .info + } + }() + + let message = logEntry.buildMessage(configuration: logger) + switch logger.privacy { + case .none: + logger.logger.log(level: level, "\(message, privacy: OSLogPrivacy.public)") + case .auto: + logger.logger.log(level: level, "\(message, privacy: OSLogPrivacy.auto)") + case .private: + logger.logger.log(level: level, "\(message, privacy: OSLogPrivacy.private)") + case .sensitive: + logger.logger.log(level: level, "\(message, privacy: OSLogPrivacy.sensitive)") + } + #endif + } + } + + // Track analytics if available + if let analytics = analytics { + let analyticEntryType: EntryType + switch type { + case "request": + analyticEntryType = .request(method: method, url: url) + case "response": + analyticEntryType = .response(method: method, url: url, statusCode: statusCode ?? 0) + case "error": + analyticEntryType = .error(method: method, url: url, error: errorString ?? "Unknown error") + default: + analyticEntryType = .request(method: method, url: url) + } + + let analyticEntry = AnalyticEntry( + type: analyticEntryType, + headers: headers, + body: body, + duration: duration, + requestId: requestId, + configuration: analytics.configuration + ) + analytics.track(analyticEntry) + } + } + + private func logAndTrackRequest( + request: URLRequest, + requestId: String + ) { + logAndTrack( + type: "request", + request: request, + requestId: requestId + ) + } + + private func logAndTrackResponse( + request: URLRequest, + response: URLResponse?, + data: Data?, + requestId: String, + startTime: Date + ) { + guard let httpResponse = response as? HTTPURLResponse else { return } + + logAndTrack( + type: "response", + request: request, + response: httpResponse, + data: data, + requestId: requestId, + startTime: startTime + ) + } + + private func logAndTrackError( + request: URLRequest, + error: Error, + requestId: String + ) { + logAndTrack( + type: "error", + request: request, + error: error, + requestId: requestId + ) + } } diff --git a/Sources/FTAPIKit/URLServer.swift b/Sources/FTAPIKit/URLServer.swift index cd9a96c..703b762 100644 --- a/Sources/FTAPIKit/URLServer.swift +++ b/Sources/FTAPIKit/URLServer.swift @@ -43,12 +43,25 @@ public protocol URLServer: Server where Request == URLRequest { /// `URLSession` instance, which is used for task execution /// - Note: Provided default implementation. var urlSession: URLSession { get } + + /// Optional logger for logging requests and responses + /// - Note: Only available on iOS 14.0+, macOS 11.0+, tvOS 14.0+, watchOS 7.0+ + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + var logger: LoggerConfiguration? { get } + + /// Optional analytics for tracking requests and responses + var analytics: AnalyticsProtocol? { get } } public extension URLServer { var urlSession: URLSession { .shared } var decoding: Decoding { JSONDecoding() } var encoding: Encoding { JSONEncoding() } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + var logger: LoggerConfiguration? { nil } + + var analytics: AnalyticsProtocol? { nil } func buildRequest(endpoint: Endpoint) throws -> URLRequest { try buildStandardRequest(endpoint: endpoint) diff --git a/Tests/FTAPIKitTests/AnalyticsTests.swift b/Tests/FTAPIKitTests/AnalyticsTests.swift new file mode 100644 index 0000000..efea705 --- /dev/null +++ b/Tests/FTAPIKitTests/AnalyticsTests.swift @@ -0,0 +1,122 @@ +import Foundation +import XCTest +@testable import FTAPIKit + +class AnalyticsTests: XCTestCase { + + func testSensitivePrivacy() { + let config = AnalyticsConfiguration( + privacy: .sensitive, + unmaskedHeaders: ["public_header"], + unmaskedUrlQueries: ["public_query"], + unmaskedBodyParams: ["public_param"] + ) + + let entry = AnalyticEntry( + type: .request(method: "GET", url: "https://example.com/path?secret_query=1&public_query=2"), + headers: ["secret_header": "foo", "public_header": "bar"], + body: "{\"secret_param\": \"foo\", \"public_param\": \"bar\"}".data(using: .utf8), + configuration: config + ) + + XCTAssertEqual(entry.url, "https://example.com/path") + XCTAssertEqual(entry.headers?["secret_header"], "***") + XCTAssertEqual(entry.headers?["public_header"], "***") // Ignored + XCTAssertNil(entry.body) + } + + func testPrivatePrivacy() { + let config = AnalyticsConfiguration( + privacy: .private, + unmaskedHeaders: ["public_header"], + unmaskedUrlQueries: ["public_query"], + unmaskedBodyParams: ["public_param"] + ) + + let entry = AnalyticEntry( + type: .request(method: "GET", url: "https://example.com/path?secret_query=1&public_query=2"), + headers: ["secret_header": "foo", "public_header": "bar"], + body: "{\"secret_param\": \"foo\", \"public_param\": \"bar\"}".data(using: .utf8), + configuration: config + ) + + XCTAssertTrue(entry.url.contains("secret_query=***")) + XCTAssertTrue(entry.url.contains("public_query=2")) + XCTAssertEqual(entry.headers?["secret_header"], "***") + XCTAssertEqual(entry.headers?["public_header"], "bar") + + let bodyString = entry.body.flatMap { String(data: $0, encoding: .utf8) } + XCTAssertTrue(bodyString?.contains("\"secret_param\":\"***\"") ?? false) + XCTAssertTrue(bodyString?.contains("\"public_param\":\"bar\"") ?? false) + } + + func testNonePrivacy() { + let config = AnalyticsConfiguration( + privacy: .none + ) + + let url = "https://example.com/path?secret_query=1&public_query=2" + let headers = ["secret_header": "foo", "public_header": "bar"] + let body = "{\"secret_param\": \"foo\", \"public_param\": \"bar\"}".data(using: .utf8) + + let entry = AnalyticEntry( + type: .request(method: "GET", url: url), + headers: headers, + body: body, + configuration: config + ) + + XCTAssertEqual(entry.url, url) + XCTAssertEqual(entry.headers, headers) + XCTAssertEqual(entry.body, body) + } + + func testRecursiveBodyMasking() { + let config = AnalyticsConfiguration( + privacy: .private, + unmaskedBodyParams: ["public_param", "public_nested_object"] + ) + + let json = """ + { + \"secret_param\": \"foo\", + \"public_param\": \"bar\", + \"nested_object\": { + \"secret_nested_param\": \"baz\", + \"public_nested_object\": { + \"another_secret\": \"qux\" + } + }, + \"array_of_objects\": [ + { \"secret_in_array\": \"foo\" }, + { \"public_param\": \"visible\" } + ] + } + """.data(using: .utf8) + + let entry = AnalyticEntry( + type: .request(method: "GET", url: "https://example.com"), + body: json, + configuration: config + ) + + let body = entry.body! + let maskedJSON = try! JSONSerialization.jsonObject(with: body, options: []) as! [String: Any] + + XCTAssertEqual(maskedJSON["secret_param"] as? String, "***") + XCTAssertEqual(maskedJSON["public_param"] as? String, "bar") + + let nestedObject = maskedJSON["nested_object"] as! [String: Any] + XCTAssertEqual(nestedObject["secret_nested_param"] as? String, "***") + XCTAssertNotNil(nestedObject["public_nested_object"]) + + let publicNestedObject = nestedObject["public_nested_object"] as! [String: Any] + XCTAssertEqual(publicNestedObject["another_secret"] as? String, "qux") + + let array = maskedJSON["array_of_objects"] as! [Any] + let firstObjectInArray = array[0] as! [String: Any] + XCTAssertEqual(firstObjectInArray["secret_in_array"] as? String, "***") + let secondObjectInArray = array[1] as! [String: Any] + XCTAssertEqual(secondObjectInArray["public_param"] as? String, "visible") + } +} \ No newline at end of file diff --git a/Tests/FTAPIKitTests/LoggingTests.swift b/Tests/FTAPIKitTests/LoggingTests.swift new file mode 100644 index 0000000..9e7a742 --- /dev/null +++ b/Tests/FTAPIKitTests/LoggingTests.swift @@ -0,0 +1,207 @@ +import Foundation +import XCTest +@testable import FTAPIKit + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +class LoggingTests: XCTestCase { + + func testLoggerConfigurationInitialization() { + let configuration = LoggerConfiguration() + XCTAssertNotNil(configuration) + XCTAssertEqual(configuration.subsystem, "com.ftapikit.networking") + XCTAssertEqual(configuration.category, "networking") + } + + func testLoggerConfigurationWithCustomSettings() { + let configuration = LoggerConfiguration( + subsystem: "com.test", + category: "test", + privacy: .sensitive + ) + XCTAssertEqual(configuration.subsystem, "com.test") + XCTAssertEqual(configuration.category, "test") + XCTAssertEqual(configuration.privacy, .sensitive) + } + + func testLoggerConfigurationDataDecoder() { + let jsonData = """ + {"name": "test", "value": 123} + """.data(using: .utf8)! + + let prettyJSON = LoggerConfiguration.defaultDataDecoder(jsonData) + XCTAssertNotNil(prettyJSON) + XCTAssertTrue(prettyJSON!.contains("\n")) // Should be pretty printed + + let utf8Data = "simple text".data(using: .utf8)! + let utf8Result = LoggerConfiguration.utf8DataDecoder(utf8Data) + XCTAssertEqual(utf8Result, "simple text") + + let sizeResult = LoggerConfiguration.sizeOnlyDataDecoder(utf8Data) + XCTAssertEqual(sizeResult, "<11 bytes>") + } + + func testLogRequest() { + let configuration = LoggerConfiguration() + let headers = ["Authorization": "Bearer token123", "Content-Type": "application/json"] + let body = "{\"test\": \"data\"}".data(using: .utf8)! + let logEntry = LogEntry( + type: .request(method: "POST", url: "https://api.example.com/test"), + headers: headers, + body: body, + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + XCTAssertEqual(configuration.subsystem, "com.ftapikit.networking") + XCTAssertEqual(configuration.category, "networking") + + // Test that LogEntry can be built without crashing + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[REQUEST]")) + } + + func testLogResponse() { + let configuration = LoggerConfiguration() + let headers = ["Content-Type": "application/json"] + let body = "{\"success\": true}".data(using: .utf8)! + let logEntry = LogEntry( + type: .response(method: "POST", url: "https://api.example.com/test", statusCode: 200), + headers: headers, + body: body, + duration: 0.5, + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + + // Test that LogEntry can be built without crashing + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[RESPONSE]")) + } + + func testLogError() { + let configuration = LoggerConfiguration() + let logEntry = LogEntry( + type: .error(method: "POST", url: "https://api.example.com/test", error: "Network error"), + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + + // Test that LogEntry can be built without crashing + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[ERROR]")) + } + + func testLogErrorWithData() { + let configuration = LoggerConfiguration() + let errorData = "{\"error\": \"Invalid JSON\"}".data(using: .utf8)! + let logEntry = LogEntry( + type: .error(method: "POST", url: "https://api.example.com/test", error: "Decoding error"), + body: errorData, + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + + // Test that LogEntry can be built without crashing and includes data + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[ERROR]")) + XCTAssertTrue(message.contains("Data:")) + } + + func testSensitiveHeadersMasking() { + let configuration = LoggerConfiguration() + let headers = [ + "Authorization": "Bearer token123", + "Content-Type": "application/json", + "X-API-Key": "secret-key" + ] + let logEntry = LogEntry( + type: .request(method: "GET", url: "https://api.example.com/test"), + headers: headers, + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + + // Test that LogEntry can be built without crashing and includes headers + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[REQUEST]")) + XCTAssertTrue(message.contains("Headers:")) + } + + func testSensitiveBodyMasking() { + let configuration = LoggerConfiguration() + let body = "{\"password\": \"secret123\", \"username\": \"user\"}".data(using: .utf8)! + let logEntry = LogEntry( + type: .request(method: "POST", url: "https://api.example.com/test"), + body: body, + requestId: "test-request-id" + ) + + // Test that configuration is properly initialized + XCTAssertNotNil(configuration) + + // Test that LogEntry can be built without crashing and includes body + let message = logEntry.buildMessage(configuration: configuration) + XCTAssertFalse(message.isEmpty) + XCTAssertTrue(message.contains("[REQUEST]")) + XCTAssertTrue(message.contains("Body:")) + } + + + func testLogEntryBuildMessage() { + // Test request message + let requestEntry = LogEntry( + type: .request(method: "POST", url: "https://api.example.com/users"), + headers: ["Content-Type": "application/json"], + body: "{\"username\": \"test\"}".data(using: .utf8)!, + requestId: "abc12345" + ) + + let configuration = LoggerConfiguration() + let requestMessage = requestEntry.buildMessage(configuration: configuration) + XCTAssertTrue(requestMessage.contains("[REQUEST]")) + XCTAssertTrue(requestMessage.contains("POST")) + XCTAssertTrue(requestMessage.contains("https://api.example.com/users")) + XCTAssertTrue(requestMessage.contains("Headers:")) + XCTAssertTrue(requestMessage.contains("Body:")) + + // Test response message + let responseEntry = LogEntry( + type: .response(method: "POST", url: "https://api.example.com/users", statusCode: 201), + headers: ["Content-Type": "application/json"], + body: "{\"id\": 123}".data(using: .utf8)!, + duration: 0.5, + requestId: "abc12345" + ) + + let responseMessage = responseEntry.buildMessage(configuration: configuration) + XCTAssertTrue(responseMessage.contains("[RESPONSE]")) + XCTAssertTrue(responseMessage.contains("201")) + XCTAssertTrue(responseMessage.contains("500.00ms")) + + // Test error message + let errorEntry = LogEntry( + type: .error(method: "POST", url: "https://api.example.com/users", error: "Network error"), + body: "{\"error\": \"Connection failed\"}".data(using: .utf8)!, + requestId: "abc12345" + ) + + let errorMessage = errorEntry.buildMessage(configuration: configuration) + XCTAssertTrue(errorMessage.contains("[ERROR]")) + XCTAssertTrue(errorMessage.contains("ERROR Network error")) + XCTAssertTrue(errorMessage.contains("Data:")) + } +} \ No newline at end of file diff --git a/Tests/FTAPIKitTests/Mockups/Servers.swift b/Tests/FTAPIKitTests/Mockups/Servers.swift index 685dae6..9d3c55f 100644 --- a/Tests/FTAPIKitTests/Mockups/Servers.swift +++ b/Tests/FTAPIKitTests/Mockups/Servers.swift @@ -29,3 +29,14 @@ struct ErrorThrowingServer: URLServer { let urlSession = URLSession(configuration: .ephemeral) let baseUri = URL(string: "http://httpbin.org/")! } + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +struct TestServerWithCustomLogger: URLServer { + let urlSession = URLSession(configuration: .ephemeral) + let baseUri = URL(string: "https://api.example.com/")! + let logger: LoggerConfiguration? + + init(logger: LoggerConfiguration) { + self.logger = logger + } +} diff --git a/Tests/FTAPIKitTests/URLServerLoggingTests.swift b/Tests/FTAPIKitTests/URLServerLoggingTests.swift new file mode 100644 index 0000000..f7d8583 --- /dev/null +++ b/Tests/FTAPIKitTests/URLServerLoggingTests.swift @@ -0,0 +1,99 @@ +import Foundation +import XCTest +@testable import FTAPIKit + +#if canImport(os.log) +import os.log +#endif + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +class URLServerLoggingTests: XCTestCase { + + var server: TestServerWithLogging! + + override func setUp() { + super.setUp() + server = TestServerWithLogging() + } + + override func tearDown() { + server = nil + super.tearDown() + } + + func testRequestLogging() { + // Given + let server = TestServerWithLogging() + + // When - test that server can be created with logger + XCTAssertNotNil(server.logger) + + // Then - test passes if no crash occurs + } + + func testCustomLogger() { + // Given + let customLogger = MockLogger() + let server = TestServerWithCustomLogger(logger: customLogger.configuration) + + // When - test that server can be created with custom logger + XCTAssertNotNil(server.logger) + + // Then - test passes if no crash occurs + } + + func testResponseLogging() { + // Given + let server = TestServerWithLogging() + + // When - test that server can be created with logger + XCTAssertNotNil(server.logger) + + // Then - test passes if no crash occurs + } + + func testErrorLogging() { + // Given - use a server that will definitely fail + let failingServer = TestServerWithLogging(baseUri: URL(string: "https://this-domain-does-not-exist-12345.com/")!) + let endpoint = GetEndpoint() + let expectation = XCTestExpectation(description: "Request completed") + + // When + failingServer.call(endpoint: endpoint) { result in + expectation.fulfill() + } + + // Then + wait(for: [expectation], timeout: 5.0) + // Test passes if no crash occurs + } +} + +// MARK: - Test Server with Logging + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +class TestServerWithLogging: URLServer { + typealias ErrorType = APIError.Standard + + let baseUri: URL + let urlSession: URLSession + let logger: LoggerConfiguration? + + init(baseUri: URL = URL(string: "http://httpbin.org/")!, logger: LoggerConfiguration? = LoggerConfiguration()) { + self.baseUri = baseUri + self.urlSession = URLSession(configuration: .ephemeral) + self.logger = logger + } +} + +// MARK: - Mock Logger for Testing + +#if canImport(os.log) +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +struct MockLogger { + let configuration = LoggerConfiguration( + subsystem: "com.test", + category: "test" + ) +} +#endif \ No newline at end of file