Skip to content
Open
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
15 changes: 11 additions & 4 deletions Sources/GoodNetworking/Logging/DataTaskLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "<nil>")"
}

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 ?? "<nil>")")
if let errorMessage {
logMessage.append("\n\(errorMessage)")
}

return logMessage
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions Sources/GoodNetworking/Models/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
Expand Down
20 changes: 11 additions & 9 deletions Sources/GoodNetworking/Models/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions Sources/GoodNetworking/Models/NetworkResponse.swift
Original file line number Diff line number Diff line change
@@ -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([:])
}
}

}
23 changes: 13 additions & 10 deletions Sources/GoodNetworking/Session/NetworkSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -270,7 +270,8 @@ extension NetworkSession {
}

public func request<T: Decodable>(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()
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down