diff --git a/Sources/GoodNetworking/Logging/DataTaskLogging.swift b/Sources/GoodNetworking/Logging/DataTaskLogging.swift index f352925..840222b 100644 --- a/Sources/GoodNetworking/Logging/DataTaskLogging.swift +++ b/Sources/GoodNetworking/Logging/DataTaskLogging.swift @@ -31,14 +31,21 @@ internal extension DataTaskProxy { } @NetworkActor private func prepareResponseStatus(response: URLResponse?, error: (any Error)?) -> String { - guard let response = response as? HTTPURLResponse else { return "" } + var errorMessage: String? + if error != nil { + errorMessage = "🚨 Error: \(error?.localizedDescription ?? "")" + } + + guard let response = response as? HTTPURLResponse else { + return errorMessage ?? "⁉️ Response not received, error not available." + } let statusCode = response.statusCode var logMessage = (200 ..< 300).contains(statusCode) ? "✅ \(statusCode): " : "❌ \(statusCode): " logMessage.append(HTTPURLResponse.localizedString(forStatusCode: statusCode)) - if error != nil { - logMessage.append("\n🚨 Error: \(error?.localizedDescription ?? "")") + if let errorMessage { + logMessage.append("\n\(errorMessage)") } return logMessage @@ -54,7 +61,7 @@ internal extension DataTaskProxy { } @NetworkActor private func prettyPrintMessage(data: Data?, mimeType: String? = "text/plain") -> String { - guard let data else { return "" } + guard let data, !data.isEmpty else { return "" } guard plainTextMimeTypeHeuristic(mimeType) else { return "🏞️ Detected MIME type is not plain text" } guard data.count < Self.maxLogSizeBytes else { return "💡 Data size is too big (\(data.count) bytes), console limit is \(Self.maxLogSizeBytes) bytes" diff --git a/Sources/GoodNetworking/Models/Endpoint.swift b/Sources/GoodNetworking/Models/Endpoint.swift index b9280ad..b805aac 100644 --- a/Sources/GoodNetworking/Models/Endpoint.swift +++ b/Sources/GoodNetworking/Models/Endpoint.swift @@ -143,20 +143,20 @@ public enum EndpointParameters { // MARK: - Compatibility -@available(*, deprecated) +@available(*, deprecated, message: "Encoding will be automatically determined by the kind of `parameters` in the future.") public protocol ParameterEncoding {} -@available(*, deprecated) +@available(*, deprecated, message: "Encoding will be automatically determined by the kind of `parameters` in the future.") public enum URLEncoding: ParameterEncoding { case `default` } -@available(*, deprecated) +@available(*, deprecated, message: "Encoding will be automatically determined by the kind of `parameters` in the future.") public enum JSONEncoding: ParameterEncoding { case `default` } -@available(*, deprecated) +@available(*, deprecated, message: "Encoding will be automatically determined by the kind of `parameters` in the future.") public enum AutomaticEncoding: ParameterEncoding { case `default` } diff --git a/Sources/GoodNetworking/Models/JSON.swift b/Sources/GoodNetworking/Models/JSON.swift index 852c458..83ba46e 100644 --- a/Sources/GoodNetworking/Models/JSON.swift +++ b/Sources/GoodNetworking/Models/JSON.swift @@ -71,7 +71,8 @@ import Foundation // MARK: - Initializers - /// Create JSON from raw `Data` + /// Create JSON from raw `Data`. + /// /// - Parameters: /// - data: Raw `Data` of JSON object /// - options: Optional serialization options @@ -90,8 +91,9 @@ import Foundation /// - model: `Encodable` model /// - encoder: Encoder for encoding the model public init(encodable model: any Encodable, encoder: JSONEncoder) { - if let data = try? encoder.encode(model), let converted = try? JSON(data: data) { - self = converted + if let data = try? encoder.encode(model), + let jsonData = try? JSON(data: data) { + self = jsonData } else { self = JSON.null } @@ -105,20 +107,20 @@ import Foundation /// /// - Parameter object: Object to try to represent as JSON public init(_ object: Any) { - if let data = object as? Data, let converted = try? JSON(data: data) { - self = converted - } else if let model = object as? any Encodable, let data = try? JSONEncoder().encode(model), let converted = try? JSON(data: data) { - self = converted + if let data = object as? Data, let jsonData = try? JSON(data: data) { + self = jsonData + } else if let model = object as? any Encodable, let data = try? JSONEncoder().encode(model), let jsonData = try? JSON(data: data) { + self = jsonData } else if let dictionary = object as? [String: Any] { self = JSON.dictionary(dictionary.mapValues { JSON($0) }) } else if let array = object as? [Any] { self = JSON.array(array.map { JSON($0) }) } else if let string = object as? String { self = JSON.string(string) - } else if let bool = object as? Bool { - self = JSON.bool(bool) } else if let number = object as? NSNumber { self = JSON.number(number) + } else if let bool = object as? Bool { + self = JSON.bool(bool) } else if let json = object as? JSON { self = json } else { diff --git a/Sources/GoodNetworking/Models/NetworkResponse.swift b/Sources/GoodNetworking/Models/NetworkResponse.swift new file mode 100644 index 0000000..a34c878 --- /dev/null +++ b/Sources/GoodNetworking/Models/NetworkResponse.swift @@ -0,0 +1,80 @@ +// +// NetworkResponse.swift +// GoodNetworking +// +// Created by Filip Šašala on 30/11/2025. +// + +import Foundation + +/// Wraps the payload returned from `URLSession` with +/// metadata describing the HTTP response. +public struct NetworkResponse: Sendable { + + /// Raw body returned by the server. + public let body: Data + + /// Original `URLResponse` instance for advanced access when needed. + public let urlResponse: URLResponse? + + /// HTTP headers resolved and stored eagerly for concurrency safety. + public let headers: HTTPHeaders + + /// HTTP specific response, if available. + public var httpResponse: HTTPURLResponse? { + urlResponse as? HTTPURLResponse + } + + /// Final URL of the response. + public var url: URL? { + urlResponse?.url + } + + /// MIME type announced by the server. + public var mimeType: String? { + urlResponse?.mimeType + } + + /// Expected length of the body. + public var expectedContentLength: Int64 { + urlResponse?.expectedContentLength ?? -1 // NSURLResponseUnknownLength + } + + /// Text encoding specified by the response. + public var textEncodingName: String? { + urlResponse?.textEncodingName + } + + /// Suggested filename inferred by Foundation. + public var suggestedFilename: String? { + urlResponse?.suggestedFilename + } + + /// HTTP status code (or `-1` when not available). + public var statusCode: Int { + httpResponse?.statusCode ?? -1 + } + + /// Raw header dictionary exposed without additional processing. + public var allHeaderFields: [AnyHashable: Any]? { + httpResponse?.allHeaderFields + } + + internal init(data: Data, response: URLResponse?) { + self.body = data + self.urlResponse = response + + // decode HTTP headers if possible + if let httpResponse = response as? HTTPURLResponse { + var flattened: [String: String] = [:] + httpResponse.allHeaderFields.forEach { header in + guard let key = header.key as? String else { return } + flattened[key] = String(describing: header.value) + } + self.headers = HTTPHeaders(flattened) + } else { + self.headers = HTTPHeaders([:]) + } + } + +} diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 48ca2f8..9eb1c64 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -180,7 +180,7 @@ extension NetworkSessionDelegate: URLSessionDelegate { case .deny(let reason): networkSession.getLogger().logNetworkEvent( - message: reason, + message: reason ?? "Denied for unspecified reasons", level: .error, file: #file, line: #line @@ -270,7 +270,8 @@ extension NetworkSession { } public func request(endpoint: Endpoint) async throws(NetworkError) -> T { - let data = try await request(endpoint: endpoint) as Data + let response: NetworkResponse = try await request(endpoint: endpoint) + let data = response.body // handle decoding corner cases var decoder = JSONDecoder() @@ -306,8 +307,8 @@ extension NetworkSession { @_disfavoredOverload public func request(endpoint: Endpoint) async throws(NetworkError) -> JSON { - let responseData = try await request(endpoint: endpoint) as Data - guard let json = try? JSON(data: responseData) else { + let response: NetworkResponse = try await request(endpoint: endpoint) + guard let json = try? JSON(data: response.body) else { throw URLError(.cannotDecodeRawData).asNetworkError() } return json @@ -316,7 +317,7 @@ extension NetworkSession { // MARK: Raw @discardableResult - public func request(endpoint: Endpoint) async throws(NetworkError) -> Data { + public func request(endpoint: Endpoint) async throws(NetworkError) -> NetworkResponse { let endpointPath = await endpoint.path.resolveUrl() let url: URL @@ -413,7 +414,7 @@ extension NetworkSession { private extension NetworkSession { - func executeRequest(request: inout URLRequest) async throws(NetworkError) -> Data { + func executeRequest(request: inout URLRequest) async throws(NetworkError) -> NetworkResponse { // Content type let httpMethodSupportsBody = request.method.hasRequestBody let httpMethodHasBody = (request.httpBody != nil) @@ -450,17 +451,19 @@ private extension NetworkSession { do { let data = try await dataTaskProxy.data() closeProxyForTask(dataTask) - + let validator = DefaultValidationProvider() - let statusCode = (dataTask.response as? HTTPURLResponse)?.statusCode ?? -1 + let response = dataTask.response + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + try validator.validate(statusCode: statusCode, data: data) - return data + return NetworkResponse(data: data, response: response) } catch let networkError { return try await retryRequest(request: &request, error: networkError) } } - func retryRequest(request: inout URLRequest, error networkError: NetworkError) async throws(NetworkError) -> Data { + func retryRequest(request: inout URLRequest, error networkError: NetworkError) async throws(NetworkError) -> NetworkResponse { let retryResult = try await interceptor.retry(urlRequest: &request, for: self, dueTo: networkError) switch retryResult {