diff --git a/Sources/GoodNetworking/Models/Endpoint.swift b/Sources/GoodNetworking/Models/Endpoint.swift index c54732f..2692ab3 100644 --- a/Sources/GoodNetworking/Models/Endpoint.swift +++ b/Sources/GoodNetworking/Models/Endpoint.swift @@ -28,21 +28,23 @@ public protocol Endpoint { @available(*, deprecated, message: "Encoding will be automatically determined by the kind of `parameters` in the future.") var encoding: ParameterEncoding { get } - /// Creates a URL by combining `path` with `baseUrl`. + /// Creates a URL by resolving `path` over `baseUrl`. + /// /// This function is a customization point for modifying the URL by current runtime, /// for example for API versioning or platform separation. + /// + /// Note that this function will be only called if the ``path`` resolved + /// is a relative URL. If ``path`` specifies an absolute URL, it will be + /// used instead, without any modifications. + /// /// - Parameter baseUrl: Base URL for the request to combine with. - /// - Throws: If creating a concrete URL fails. - /// - Returns: URL for the request. + /// - Returns: URL for the request or `nil` if such URL cannot be constructed. @NetworkActor func url(on baseUrl: URLConvertible) async -> URL? } -@available(*, deprecated, message: "Default values for deprecated properties") public extension Endpoint { - - var encoding: ParameterEncoding { AutomaticEncoding.default } - + @NetworkActor func url(on baseUrl: URLConvertible) async -> URL? { let baseUrl = await baseUrl.resolveUrl() let path = await path.resolveUrl() @@ -53,6 +55,13 @@ public extension Endpoint { } +@available(*, deprecated, message: "Default values for deprecated properties") +public extension Endpoint { + + var encoding: ParameterEncoding { AutomaticEncoding.default } + +} + // MARK: - Parameters /// Enum that represents the data to be sent with the request, diff --git a/Sources/GoodNetworking/Models/EndpointFactory.swift b/Sources/GoodNetworking/Models/EndpointBuilder.swift similarity index 85% rename from Sources/GoodNetworking/Models/EndpointFactory.swift rename to Sources/GoodNetworking/Models/EndpointBuilder.swift index 2742f77..7ebf87f 100644 --- a/Sources/GoodNetworking/Models/EndpointFactory.swift +++ b/Sources/GoodNetworking/Models/EndpointBuilder.swift @@ -1,5 +1,5 @@ // -// EndpointFactory.swift +// EndpointBuilder.swift // GoodNetworking // // Created by Filip Šašala on 06/08/2025. @@ -10,7 +10,7 @@ import Foundation /// Modified implementation of factory pattern to build /// endpoint as a series of function calls instead of conforming /// to a protocol. -public final class EndpointFactory: Endpoint { +public final class EndpointBuilder: Endpoint { public var path: URLConvertible public var method: HTTPMethod = .get @@ -29,7 +29,7 @@ public final class EndpointFactory: Endpoint { } -public extension EndpointFactory { +public extension EndpointBuilder { func method(_ method: HTTPMethod) -> Self { self.method = method @@ -82,7 +82,7 @@ public extension EndpointFactory { } -private extension EndpointFactory { +private extension EndpointBuilder { func assertBothQueryAndBodyUsage() { assert(self.parameters == nil, "Support for query and body parameters at the same time is currently not available.") @@ -90,6 +90,9 @@ private extension EndpointFactory { } -public func at(_ path: URLConvertible) -> EndpointFactory { - EndpointFactory(at: path) +public func at(_ path: URLConvertible) -> EndpointBuilder { + EndpointBuilder(at: path) } + +@available(*, deprecated, renamed: "EndpointBuilder") +public typealias EndpointFactory = EndpointBuilder diff --git a/Sources/GoodNetworking/Models/URLConvertible.swift b/Sources/GoodNetworking/Models/URLConvertible.swift index 90f95e8..8709f78 100644 --- a/Sources/GoodNetworking/Models/URLConvertible.swift +++ b/Sources/GoodNetworking/Models/URLConvertible.swift @@ -48,3 +48,48 @@ extension String: URLConvertible { } } + +// MARK: - Extensions + +extension URL { + + /// Initialize with optional string. + /// + /// Returns `nil` if a `URL` cannot be formed with the string (for example, if the string + /// contains characters that are illegal in a URL, or is an empty string, or is `nil`). + /// - Parameter string: String containing the URL or `nil` + public init?(_ string: String?) { + guard let string else { return nil } + self.init(string: string) + } + + /// Checks if URL contains a non-empty scheme. + /// + /// Returns `true` if ``scheme`` is not `nil` and not empty, eg. `https://` + var hasScheme: Bool { + if let scheme, !scheme.isEmpty { + return true + } else { + return false + } + } + + /// Checks if URL contains a non-empty host. + /// + /// Returns `true` if ``host`` is not `nil` and not empty, eg. `goodrequest.com` + var hasHost: Bool { + if let host, !host.isEmpty { + return true + } else { + return false + } + } + + /// Checks if URL can be considered an absolute URL. + /// + /// Returns `true` if the URL is a file URL or has both host and scheme. + var isAbsolute: Bool { + return isFileURL || hasScheme && hasHost + } + +} diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 54447e9..3511475 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -9,21 +9,27 @@ import Foundation // MARK: - Network error -/// Top level error, which can occur in all networking operations in this library. -/// -/// The error is organized as follows: -/// - Local errors (`URLError`): errors which affect only the local state. May contain -/// failed networking operations, no connection errors, invalid URL errors etc. -/// - Remote errors (``HTTPError``): errors which occured as a result of invalid operation -/// over remote state. This contains all HTTP errors, invalid API calls etc., but also means that -/// the request itself on network level has succeeded. -/// - Decoding errors (`DecodingError`): errors which occured during decoding. The request -/// has succeeded, returned a valid, success, response, but could not be decoded to a valid -/// data type in the client. +/// Top level error, which can occur in all networking operations in GoodNetworking. +/// +/// All underlying errors are fall into three categories, which are represented +/// by respective enum cases into local errors, remote errors and coding errors. +/// +/// - Local errors (`URLError`) +/// - Remote errors (``HTTPError``) +/// - Decoding errors (`DecodingError`) public enum NetworkError: LocalizedError { + /// Errors which affect only the local state. May include failed networking + /// operations, no connection errors, invalid URL errors etc. case local(URLError) + + /// Errors which occured as a result of invalid operation over remote state. + /// This contains all HTTP errors, invalid API calls etc., but also means + /// the request has succeeded on the network layer. case remote(HTTPError) + + /// Errors which occured during decoding. The request has succeeded + /// with a valid response, but could not be decoded to any data type in the client. case decoding(DecodingError) public var errorDescription: String? { @@ -38,6 +44,12 @@ public enum NetworkError: LocalizedError { return decodingError.localizedDescription } } + +} + +// MARK: - Network error extensions + +extension NetworkError { /// HTTP status code, if the error is a `remote` ``HTTPError``, or `nil` otherwise. /// @@ -50,7 +62,21 @@ public enum NetworkError: LocalizedError { return nil } } - + + /// Attempts decoding failure response as a decodable error structure. + /// + /// If the network error is ``local(_:)`` or ``decoding(_:)`` error, + /// this function returns `nil`. + /// If the network error is ``remote(_:)``, response data is decoded as `T` + /// using JSON decoder. + public func remote(as errorType: T.Type) -> T? { + if case .remote(let httpError) = self { + return try? JSONDecoder().decode(T.self, from: httpError.errorResponse) + } else { + return nil + } + } + } // MARK: - Local error extensions diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 9f6e64d..f23aa7a 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -212,7 +212,7 @@ extension NetworkSession { // handle decoding corner cases var decoder = JSONDecoder() switch T.self { - case is Data.Type: + case is Data.Type, is Optional.Type: return data as! T case let t as WithCustomDecoder: @@ -248,10 +248,25 @@ extension NetworkSession { @discardableResult public func request(endpoint: Endpoint) async throws(NetworkError) -> Data { - guard let basePath = await baseUrl.resolveUrl()?.absoluteString, - let url = await endpoint.url(on: basePath) - else { - throw URLError(.badURL).asNetworkError() + let endpointPath = await endpoint.path.resolveUrl() + let url: URL + + // If endpoint already contains an absolute path, do not concatenate + // with baseURL and use that instead + if let endpointPath, endpointPath.isAbsolute { + url = endpointPath + } else { + // If endpoint has only relative path, resolve it over baseURL + let baseUrl = await baseUrl.resolveUrl() + let endpointResolvedUrl = await endpoint.url(on: baseUrl) + + // If neither endpoint nor baseURL are specified, URL cannot be resolved + guard let endpointResolvedUrl else { + throw URLError(.badURL).asNetworkError() + } + + // URL is resolved + url = endpointResolvedUrl } // url + method