diff --git a/README.md b/README.md index 2242863..a6abbaa 100644 --- a/README.md +++ b/README.md @@ -337,27 +337,33 @@ The default value is always `en`. With other languages, we use values compliant ## Logging -For logging purposes `WPNLogger` that prints to the console is used. - - -Note that logging to the console is available only when the library is compiled with the `DEBUG` or `WPN_ENABLE_LOGGING` Swift compile condition. - +You can set up logging for the library using the `WPNLogger` class. ### Verbosity Level -You can limit the amount of logged information via `verboseLevel` property. +You can limit the amount of logged information via the `verboseLevel` property. -| Level | Description | -| --- | --- | -| `off` | Silences all messages. | -| `errors` | Only errors will be printed to the debug console. | -| `warnings` _(default)_ | Errors and warnings will be printed to the debug console. | -| `all` | All messages will be printed to the debug console. | +| Level | Description | +| ---------------------- | ------------------------------------------------- | +| `off` | Silences all messages. | +| `errors` | Only errors will be logged. | +| `warnings` _(default)_ | Errors and warnings will be logged. | +| `info` | Error, warning and info messages will be logged. | +| `all` | All messages will be logged. | ### Character limit To prevent huge logs from being printed out, there is a default limit of 12,000 characters per log in place. You can change this via `WPNLogger.characterLimit`. +### HTTP traffic logs + +- You can turn on or off logging of the requests and responses with the `WPNLogger.logHttpTraffic` property. +- You can filter which headers will be logged with the `WPNLogger.httpHeadersToSkip` property. + +### Logger Delegate + +In case you want to process logs on your own (for example log into a file or some cloud service), you can set `WPNLogger.delegate`. + ## Web Documentation diff --git a/Sources/WultraPowerauthNetworking/WPNHttpClient.swift b/Sources/WultraPowerauthNetworking/WPNHttpClient.swift index ccad237..4be39ee 100644 --- a/Sources/WultraPowerauthNetworking/WPNHttpClient.swift +++ b/Sources/WultraPowerauthNetworking/WPNHttpClient.swift @@ -80,22 +80,27 @@ class WPNHttpClient: NSObject, URLSessionDelegate { private extension URLRequest { func printToConsole() { - D.print("WPNHttpClient Request") - D.print("- URL: POST - \(url?.absoluteString ?? "no URL")") - D.print("- Headers: \(allHTTPHeaderFields?.betterDescription ?? "no headers")") - D.print("- Body: \(httpBody?.utf8string ?? "empty body")") + if D.logHttpTraffic { + D.info("WPNHttpClient Request") + D.info("- URL: POST - \(url?.absoluteString ?? "no URL")") + D.info("- Headers: \(D.httpHeadersToSkip.filterHeaders(headers: allHTTPHeaderFields))") + D.debug("- Body: \(httpBody?.utf8string ?? "empty body")") + } } } private extension HTTPURLResponse { func printToConsole(withData data: Data?, andError error: Error?) { - D.print("WPNHttpClient Response") - D.print("- URL: POST - \(url?.absoluteString ?? "no URL")") - D.print("- Status code: \(statusCode)") - D.print("- Headers: \(allHeaderFields.betterDescription)") - D.print("- Body: \(data?.utf8string ?? "empty body")") - if let error = error { - D.print("- Error: \(error.localizedDescription)") + if D.logHttpTraffic { + D.info("WPNHttpClient Response") + D.info("- URL: POST - \(url?.absoluteString ?? "no URL")") + D.info("- Status code: \(statusCode)") + D.info("- Headers: \(D.httpHeadersToSkip.filterHeaders(headers: Dictionary(uniqueKeysWithValues: allHeaderFields.map { ($0.key.description, "\($0.value)") })))") + D.debug("- Body: \(data?.utf8string ?? "empty body")") + + if let error = error { + D.error("- Error: \(error.localizedDescription)") + } } } } @@ -105,9 +110,3 @@ private extension Data { return String(bytes: self, encoding: .utf8) } } - -private extension Dictionary where Key: CustomStringConvertible, Value: Any { - var betterDescription: String { - return map({($0.key.description, $0.value)}).description - } -} diff --git a/Sources/WultraPowerauthNetworking/WPNLogger.swift b/Sources/WultraPowerauthNetworking/WPNLogger.swift index 337a355..83729d3 100644 --- a/Sources/WultraPowerauthNetworking/WPNLogger.swift +++ b/Sources/WultraPowerauthNetworking/WPNLogger.swift @@ -16,52 +16,133 @@ import Foundation -/// WPNLogger provides simple logging facility available for DEBUG build of the library. +/// Level of the log +public enum WPNLogLevel { + /// Debug logs. Might contain sensitive data like body of the request etc. + /// You should only use this level during development. + case debug + /// Regular library logic logs + case info + /// Non-critical warning + case warning + /// Error happened + case error + + fileprivate var minVerboseLevel: WPNLogger.VerboseLevel { + return switch self { + case .debug: .debug + case .info: .info + case .warning: .warnings + case .error: .errors + } + } + + fileprivate var logName: String { + return switch self { + case .debug: "DEBUG" + case .info: "INFO" + case .warning: "WARNING" + case .error: "ERROR" + } + } +} + +/// Delegate that can further process logs from the library +public protocol WPNLoggerDelegate: AnyObject { + + /// If the delegate should follow selected verbosity level. + /// + /// When set to true, then (for example) if `errors` is selected as a `verboseLevel`, only `error` logLevel will be called. + /// When set to false, all methods might be called no matter the selected `verboseLevel`. + var wpnFollowVerboseLevel: Bool { get } + + /// Log was recorded + /// - Parameters: + /// - message: Message of the log + /// - logLevel: Log level + func wpnLog(message: String, logLevel: WPNLogLevel) +} + +/// WPNLogger provides simple logging facility. public class WPNLogger { - /// Defines verbose level for this simple debugging facility. + /// Verbose level of the logger. public enum VerboseLevel: Int { /// Silences all messages. case off = 0 - /// Only errors will be printed to the debug console. + /// Only errors will be printed to the system console. case errors = 1 - /// Errors and warnings will be printed to the debug console. + /// Errors and warnings will be printed to the system console. case warnings = 2 - /// All messages will be printed to the debug console. - case all = 3 + /// Error ,warning and info messages will be printed to the system console. + case info = 3 + /// All messages will be printed to the system console - including debug messages + case debug = 4 } - /// Current verbose level. Note that value is ignored for non-DEBUG builds. + /// Logger delegate + public static weak var delegate: WPNLoggerDelegate? + + /// Current verbose level. `warnings` by default public static var verboseLevel: VerboseLevel = .warnings - /// Character limit for single log message. Default is 12 000. Unlimited when nil + /// If HTTP traffic should be reported by this logger. `true` by default + /// + /// You can use this option to stop log from the HTTP traffic when you setup your own logging logic + /// via the `responseDelegate` and `requestDelegate` in the `WPNNetworkingService`. + public static var logHttpTraffic = true + + /// Headers that won't be logged. + /// + /// Default headers to skip are: + /// ``` + /// "accept-language", "content-type", "content-length", + /// "accept-language", "transfer-encoding", "date", + /// "server", "user-agent", "connection", "x-content-type-options", + /// "x-xss-protection", "cache-control", "pragma", "expires", + /// "x-frame-options", "vary" + /// ``` + public static let httpHeadersToSkip = HeaderBlockList() + + /// Character limit for single log message. Default is `12 000`. Unlimited when nil public static var characterLimit: Int? = 12_000 - /// Prints simple message to the debug console. - static func print(_ message: @autoclosure () -> String) { - #if DEBUG || WPN_ENABLE_LOGGING - if verboseLevel == .all { - Swift.print("[WPN] \(message().limit(characterLimit))") - } - #endif + /// Prints simple message to the system console. + static func debug(_ message: @autoclosure () -> String) { + log(message(), level: .debug) + } + + /// Prints simple message to the system console. + static func info(_ message: @autoclosure () -> String) { + log(message(), level: .info) } - /// Prints warning message to the debug console. + /// Prints warning message to the system console. static func warning(_ message: @autoclosure () -> String) { - #if DEBUG || WPN_ENABLE_LOGGING - if verboseLevel.rawValue >= VerboseLevel.warnings.rawValue { - Swift.print("[WPN] WARNING: \(message().limit(characterLimit))") - } - #endif + log(message(), level: .warning) } - /// Prints error message to the debug console. + /// Prints error message to the system console. static func error(_ message: @autoclosure () -> String) { - #if DEBUG || WPN_ENABLE_LOGGING - if verboseLevel != .off { - Swift.print("[WPN] ERROR: \(message().limit(characterLimit))") + log(message(), level: .error) + } + + private static func log(_ message: @autoclosure () -> String, level: WPNLogLevel) { + let levelAllowed = level.minVerboseLevel.rawValue <= verboseLevel.rawValue + let forceReport = delegate?.wpnFollowVerboseLevel == false + guard levelAllowed || forceReport else { + // not logging + return + } + + let msg = message().limit(characterLimit) + + if levelAllowed { + print("[WPN:\(level.logName)] \(msg)") + } + if levelAllowed || forceReport { + delegate?.wpnLog(message: msg, logLevel: level) } - #endif } #if DEBUG @@ -87,6 +168,82 @@ public class WPNLogger { #endif } +/// Headers to skip when logging. +/// +/// Note that all headers are transformed to lowercase variant when added. +/// +/// Default headers to skip are: +/// ``` +/// "accept-language", "content-type", "content-length", "accept-language", "transfer-encoding", "date", "server", "user-agent", +/// "connection", "x-content-type-options", "x-xss-protection", "cache-control", "pragma", "expires", "x-frame-options", "vary" +/// ``` +/// +public class HeaderBlockList { + + private var headersToSkp = [ + "accept-language", "content-type", "content-length", "accept-language", "transfer-encoding", "date", "server", "user-agent", + "connection", "x-content-type-options", "x-xss-protection", "cache-control", "pragma", "expires", "x-frame-options", "vary" + ] + + /// Adds element to the block list. + /// - Parameter element: HTTP header key to block. + public func add(element: String) { + headersToSkp.append(element.lowercased()) + } + + /// Adds elements to the block list. + /// - Parameter element: HTTP header keys to block. + public func add(elements: [String]) { + headersToSkp.append(contentsOf: elements.map { $0.lowercased() }) + } + + /// Removes element from the block list. + /// - Parameter element: HTTP header key to remove. + public func remove(element: String) { + headersToSkp.removeAll { $0 == element.lowercased() } + } + + /// Removes elements from the block list. + /// - Parameter element: HTTP header keys to remove. + public func removeAll(elements: [String]) { + elements.map { $0.lowercased() }.forEach { + if let idx = headersToSkp.firstIndex(of: $0) { + headersToSkp.remove(at: idx) + } + } + } + + /// Remove all + public func removeAll() { + headersToSkp.removeAll() + } + + /// Returns array of headers to skip + /// - Returns: Headers to skip + public func headersToSkip() -> [String] { + return Array(headersToSkp) + } + + func filterHeaders(headers: [String: String]?) -> String { + + guard let headers else { + return "no headers" + } + + var result = "" + var skipped = 0 + + for header in headers { + if headersToSkp.contains(where: { $0 == header.key.lowercased() }) { + skipped += 1 + } else { + result += "\n - \(header.key): \(header.value)" + } + } + return "\(skipped) filtered out" + result + } +} + private extension String { func limit(_ characterLimit: Int?) -> String { guard let cl = characterLimit else { diff --git a/Sources/WultraPowerauthNetworking/WPNNetworkingService.swift b/Sources/WultraPowerauthNetworking/WPNNetworkingService.swift index c614214..e3ec9e4 100644 --- a/Sources/WultraPowerauthNetworking/WPNNetworkingService.swift +++ b/Sources/WultraPowerauthNetworking/WPNNetworkingService.swift @@ -215,6 +215,9 @@ public class WPNNetworkingService { self.responseDelegate?.responseReceived(from: request.url, statusCode: urlResponse?.statusCode, body: receivedData) resp = envelope case .encrypted(let envelope, let decryptedData): + if D.logHttpTraffic { + D.debug("Decrypted response from \(request.url.absoluteString):\n\(String(decoding: decryptedData, as: UTF8.self) ?? "empty")") + } self.responseDelegate?.encryptedResponseReceived(from: request.url, statusCode: urlResponse?.statusCode, body: receivedData, decrypted: decryptedData) resp = envelope case .failed: