diff --git a/Sources/SwiftAPIClient/Extensions/URLComponentBuilder.swift b/Sources/SwiftAPIClient/Extensions/URLComponentBuilder.swift new file mode 100644 index 0000000..a2cd83c --- /dev/null +++ b/Sources/SwiftAPIClient/Extensions/URLComponentBuilder.swift @@ -0,0 +1,300 @@ +import Foundation + +/// A protocol that defines a type capable of configuring URLComponents. +public protocol URLComponentBuilder { + + /// The type of the result produced by the configure method. + associatedtype BuildResult = Self + + /// Configures URLComponents by applying a closure to modify them. + /// + /// - Parameter builder: A closure that modifies the URLComponents. + /// - Throws: Rethrows any error thrown by the builder closure. + /// - Returns: The modified URL or URLComponents as the result type. + func configureURLComponents(_ builder: (inout URLComponents) throws -> Void) rethrows -> BuildResult +} + +extension URLComponents: URLComponentBuilder { + + /// Configures URLComponents by applying a closure to modify them. + /// + /// - Parameter builder: A closure that modifies the URLComponents. + /// - Throws: Rethrows any error thrown by the builder closure. + /// - Returns: The modified URLComponents instance. + public func configureURLComponents(_ builder: (inout URLComponents) throws -> Void) rethrows -> URLComponents { + var value = self + try builder(&value) + return value + } +} + +extension URL: URLComponentBuilder { + + /// Configures URL by applying a closure to modify URLComponents. + /// + /// - Parameter builder: A closure that modifies the URLComponents. + /// - Throws: Rethrows any error thrown by the builder closure. + /// - Returns: The modified URL instance or the original URL if the modification fails. + public func configureURLComponents(_ builder: (inout URLComponents) throws -> Void) rethrows -> URL { + guard + var value = URLComponents(url: self, resolvingAgainstBaseURL: false) ?? URLComponents(url: self, resolvingAgainstBaseURL: true) + else { return self } + try builder(&value) + return value.url ?? self + } +} + +extension HTTPRequestComponents: URLComponentBuilder { + + /// Configures HTTPRequestComponents by applying a closure to modify URLComponents. + /// + /// - Parameter builder: A closure that modifies the URLComponents. + /// - Throws: Rethrows any error thrown by the builder closure. + /// - Returns: The modified URLComponents instance. + public func configureURLComponents(_ builder: (inout URLComponents) throws -> Void) rethrows -> HTTPRequestComponents { + var value = self + try builder(&value.urlComponents) + return value + } +} + +// MARK: - Path modifiers + +public extension URLComponentBuilder { + + /// Appends path components to the URL. + /// - Parameters: + /// - components: A variadic list of components that conform to `CustomStringConvertible`. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with updated path. + func path(_ components: any CustomStringConvertible..., percentEncoded: Bool = false) -> BuildResult { + path(components, percentEncoded: percentEncoded) + } + + /// Appends an array of path components to the URL. + /// - Parameters: + /// - components: An array of components that conform to `CustomStringConvertible`. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with updated path. + func path(_ components: [any CustomStringConvertible], percentEncoded: Bool = false) -> BuildResult { + configureURLComponents { + guard !components.isEmpty else { return } + let items = components.flatMap { + $0.description.components(separatedBy: ["/"]).filter { !$0.isEmpty } + } + for item in items { + $0.appendPath(item, percentEncoded: percentEncoded) + } + } + } +} + +// MARK: - Query modifiers + +public extension URLComponentBuilder { + + /// Adds URL query parameters using a closure providing an array of `URLQueryItem`. + /// - Parameters: + /// - items: A closure returning an array of `URLQueryItem` to be set as query parameters. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with set query parameters. + func query(_ items: @escaping @autoclosure () throws -> [URLQueryItem], percentEncoded: Bool = false) rethrows -> BuildResult { + try query(percentEncoded: percentEncoded) { + try items() + } + } + + /// Adds URL query parameters with a closure that dynamically provides an array of `URLQueryItem` based on configurations. + /// - Parameters: + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - items: A closure building an array of `URLQueryItem`. + /// - Returns: An instance with set query parameters. + func query(percentEncoded: Bool = false, _ items: @escaping () throws -> [URLQueryItem]) rethrows -> BuildResult { + try configureURLComponents { components in + try components.addQueryItems(items: items(), percentEncoded: percentEncoded) + } + } + + /// Adds a single URL query parameter. + /// - Parameters: + /// - field: The field name of the query parameter. + /// - value: The value of the query parameter. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with the specified query parameter. + func query(_ field: String, _ value: String?, percentEncoded: Bool = false) -> BuildResult { + query(value.map { [URLQueryItem(name: field, value: $0)] } ?? [], percentEncoded: percentEncoded) + } + + /// Adds a single URL query parameter. + /// - Parameters: + /// - field: The field name of the query parameter. + /// - value: The value of the query parameter, conforming to `RawRepresentable`. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with the specified query parameter. + func query(_ field: String, _ value: R?, percentEncoded: Bool = false) -> BuildResult where R.RawValue == String { + query(field, value?.rawValue, percentEncoded: percentEncoded) + } + + /// Adds URL query parameters using an `Encodable` object. + /// - Parameters: + /// - items: An `Encodable` object to be used as query parameters. + /// - queryEncoder: A `QueryEncoder` object. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with set query parameters. + @_disfavoredOverload + func query(_ items: any Encodable, queryEncoder: QueryEncoder = URLQueryEncoder(), percentEncoded: Bool = false) throws -> BuildResult { + try query(percentEncoded: true) { + try queryEncoder.encode(items, percentEncoded: !percentEncoded) + } + } + + /// Adds URL query parameters using a dictionary of JSON objects. + /// - Parameters: + /// - parameters: A dictionary of `String: Encodable?` pairs to be used as query parameters. + /// - queryEncoder: A `QueryEncoder` object. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with set query parameters. + func query(_ parameters: [String: Encodable?], queryEncoder: QueryEncoder = URLQueryEncoder(), percentEncoded: Bool = false) throws -> BuildResult { + try query(percentEncoded: true) { + try queryEncoder + .encode(parameters.compactMapValues { $0.map { AnyEncodable($0) }}, percentEncoded: !percentEncoded) + .sorted(by: { $0.name < $1.name }) + } + } + + /// Adds a single URL query parameter. + /// - Parameters: + /// - field: The field name of the query parameter. + /// - value: The value of the query parameter, conforming to `Encodable`. + /// - queryEncoder: A `QueryEncoder` object. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with the specified query parameter. + @_disfavoredOverload + func query(_ field: String, _ value: Encodable?, queryEncoder: QueryEncoder = URLQueryEncoder(), percentEncoded: Bool = false) throws -> BuildResult { + try query([field: value], queryEncoder: queryEncoder, percentEncoded: percentEncoded) + } + + /// Adds URL query parameters using a dictionary of JSON objects. + /// - Parameters: + /// - parameters: A dictionary of `String: CustomStringConvertible?` pairs to be used as query parameters. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with set query parameters. + func query(_ parameters: [String: CustomStringConvertible?], percentEncoded: Bool = false) -> BuildResult { + query(percentEncoded: percentEncoded) { + parameters.compactMapValues { $0?.queryDescription } + .map { URLQueryItem(name: $0.key, value: $0.value) } + .sorted(by: { $0.name < $1.name }) + } + } + + /// Adds a single URL query parameter. + /// - Parameters: + /// - field: The field name of the query parameter. + /// - value: The value of the query parameter, conforming to `CustomStringConvertible`. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with the specified query parameter. + @_disfavoredOverload + func query(_ field: String, _ value: CustomStringConvertible?, percentEncoded: Bool = false) -> BuildResult { + query([field: value], percentEncoded: percentEncoded) + } +} + +// MARK: - URL modifiers + +public extension URLComponentBuilder { + + /// Sets the base URL. + /// + /// - Parameters: + /// - newBaseURL: The new base URL to set. + /// - Returns: An instance with the updated base URL. + /// + /// - Note: The query, and fragment of the original URL are retained, while those of the new URL are ignored. + func baseURL(_ newBaseURL: URL) -> BuildResult { + configureURLComponents { + $0.scheme = newBaseURL.scheme +#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) + if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { + if let host = newBaseURL.host(percentEncoded: false) { + $0.host = host + } + let path = newBaseURL.path(percentEncoded: false) + if !path.isEmpty, path != "/" { + $0.prependPath(path) + } + } else { + if let host = newBaseURL.host { + $0.percentEncodedHost = host + } + if !newBaseURL.path.isEmpty, newBaseURL.path != "/" { + $0.prependPath(newBaseURL.path, percentEncoded: true) + } + } +#else + if let host = newBaseURL.host { + $0.percentEncodedHost = host + } + if !newBaseURL.path.isEmpty, newBaseURL.path != "/" { + $0.prependPath(newBaseURL.path, percentEncoded: true) + } +#endif + $0.port = newBaseURL.port + } + } + + /// Sets the scheme. + /// + /// - Parameter scheme: The new scheme to set. + /// - Returns: An instance with the updated scheme. + func scheme(_ scheme: String) -> BuildResult { + configureURLComponents { + $0.scheme = scheme + } + } + + /// Sets the host. + /// + /// - Parameters: + /// - host: The new host to set. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. + /// - Returns: An instance with the updated host. + func host(_ host: String, percentEncoded: Bool = false) -> BuildResult { + configureURLComponents { + if percentEncoded { + $0.percentEncodedHost = host + } else { + $0.host = host + } + } + } + + /// Sets the port. + /// + /// - Parameter port: The new port to set. + /// - Returns: An instance with the updated port. + func port(_ port: Int?) -> BuildResult { + configureURLComponents { + $0.port = port + } + } + + /// Sets the fragment for the url. + /// + /// - Parameter fragment: The new fragment to set. + /// - Returns: An instance with the updated port. + func fragment(_ fragment: String?) -> BuildResult { + configureURLComponents { + $0.fragment = fragment + } + } +} + +extension CustomStringConvertible { + + var queryDescription: String { + if let collection = (self as? any Collection), !(self is any StringProtocol) { + return collection.map { "\($0)" }.joined(separator: ",") + } + return description + } +} diff --git a/Sources/SwiftAPIClient/Modifiers/RequestModifiers.swift b/Sources/SwiftAPIClient/Modifiers/RequestModifiers.swift index 6b96d4a..cde885a 100644 --- a/Sources/SwiftAPIClient/Modifiers/RequestModifiers.swift +++ b/Sources/SwiftAPIClient/Modifiers/RequestModifiers.swift @@ -33,13 +33,7 @@ public extension RequestBuilder where Request == HTTPRequestComponents { /// - Returns: An instance with updated path. func path(_ components: [any CustomStringConvertible], percentEncoded: Bool = false) -> Self { modifyRequest { - guard !components.isEmpty else { return } - let items = components.flatMap { - $0.description.components(separatedBy: ["/"]).filter { !$0.isEmpty } - } - for item in items { - $0.appendPath(item, percentEncoded: percentEncoded) - } + $0 = $0.path(components, percentEncoded: percentEncoded) } } } @@ -278,8 +272,11 @@ public extension RequestBuilder where Request == HTTPRequestComponents { public extension RequestBuilder where Request == HTTPRequestComponents, Configs == APIClient.Configs { /// Adds URL query parameters using an `Encodable` object. - /// - Parameters: items: An `Encodable` object to be used as query parameters. + /// - Parameters: + /// - items: An `Encodable` object to be used as query parameters. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. /// - Returns: An instance with set query parameters. + @_disfavoredOverload func query(_ items: any Encodable, percentEncoded: Bool = false) -> Self { query(percentEncoded: true) { try $0.queryEncoder.encode(items, percentEncoded: !percentEncoded) @@ -287,7 +284,9 @@ public extension RequestBuilder where Request == HTTPRequestComponents, Configs } /// Adds URL query parameters using a dictionary of JSON objects. - /// - Parameter json: A dictionary of `String: JSON` pairs to be used as query parameters. + /// - Parameters: + /// - parameters: A dictionary of `String: Encodable?` pairs to be used as query parameters. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. /// - Returns: An instance with set query parameters. func query(_ parameters: [String: Encodable?], percentEncoded: Bool = false) -> Self { query(percentEncoded: true) { @@ -304,7 +303,7 @@ public extension RequestBuilder where Request == HTTPRequestComponents, Configs /// - Returns: An instance with the specified query parameter. @_disfavoredOverload func query(_ field: String, _ value: Encodable?, percentEncoded: Bool = false) -> Self { - query([field: value]) + query([field: value], percentEncoded: percentEncoded) } } @@ -321,33 +320,7 @@ public extension RequestBuilder where Request == HTTPRequestComponents { /// - Note: The query, and fragment of the original URL are retained, while those of the new URL are ignored. func baseURL(_ newBaseURL: URL) -> Self { modifyRequest { - $0.urlComponents.scheme = newBaseURL.scheme - #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) - if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { - if let host = newBaseURL.host(percentEncoded: false) { - $0.urlComponents.host = host - } - let path = newBaseURL.path(percentEncoded: false) - if !path.isEmpty, path != "/" { - $0.prependPath(path) - } - } else { - if let host = newBaseURL.host { - $0.urlComponents.percentEncodedHost = host - } - if !newBaseURL.path.isEmpty, newBaseURL.path != "/" { - $0.prependPath(newBaseURL.path, percentEncoded: true) - } - } - #else - if let host = newBaseURL.host { - $0.urlComponents.percentEncodedHost = host - } - if !newBaseURL.path.isEmpty, newBaseURL.path != "/" { - $0.prependPath(newBaseURL.path, percentEncoded: true) - } - #endif - $0.urlComponents.port = newBaseURL.port + $0.urlComponents = $0.urlComponents.baseURL(newBaseURL) } } @@ -413,11 +386,13 @@ public extension RequestBuilder where Request == HTTPRequestComponents { /// Sets the host for the request. /// - /// - Parameter host: The new host to set. + /// - Parameters: + /// - host: The new host to set. + /// - percentEncoded: A Boolean to determine whether to percent encode the components. Default is `false`. /// - Returns: An instance with the updated host. - func host(_ host: String) -> Self { + func host(_ host: String, percentEncoded: Bool = false) -> Self { modifyRequest { - $0.urlComponents.host = host + $0 = $0.host(host, percentEncoded: percentEncoded) } } diff --git a/Sources/SwiftAPIClient/RequestBuilder.swift b/Sources/SwiftAPIClient/RequestBuilder.swift index 50a9bd5..71a845c 100644 --- a/Sources/SwiftAPIClient/RequestBuilder.swift +++ b/Sources/SwiftAPIClient/RequestBuilder.swift @@ -26,3 +26,11 @@ public extension RequestBuilder { } } } + +public extension RequestBuilder where Request == HTTPRequestComponents { + + /// The request `URL` + var url: URL? { + try? request().url + } +} diff --git a/Sources/SwiftAPIClient/Types/HTTPRequestComponents.swift b/Sources/SwiftAPIClient/Types/HTTPRequestComponents.swift index 55ae790..413d2d1 100644 --- a/Sources/SwiftAPIClient/Types/HTTPRequestComponents.swift +++ b/Sources/SwiftAPIClient/Types/HTTPRequestComponents.swift @@ -221,48 +221,72 @@ public struct HTTPRequestComponents: Sendable, Hashable { _ pathComponent: String, percentEncoded: Bool = false ) { - var (path, query, fragment) = decomposePathIfNeeded(pathComponent) - if path.hasPrefix("/"), urlComponents.path.hasSuffix("/") { - path.removeFirst() - } else if !path.hasPrefix("/"), !urlComponents.path.hasSuffix("/") { - path = "/" + path - } - if percentEncoded { - urlComponents.percentEncodedPath += path - } else { - urlComponents.path += path - } - if !query.isEmpty { - addQueryItems(items: query, percentEncoded: percentEncoded) - } - if let fragment { - urlComponents.fragment = fragment - } + urlComponents.appendPath(pathComponent, percentEncoded: percentEncoded) } public mutating func prependPath( _ pathComponent: String, percentEncoded: Bool = false ) { + urlComponents.prependPath(pathComponent, percentEncoded: percentEncoded) + } + + mutating func addQueryItems( + items: [URLQueryItem], + percentEncoded: Bool + ) { + urlComponents.addQueryItems(items: items, percentEncoded: percentEncoded) + } +} + +extension URLComponents { + + public mutating func appendPath( + _ pathComponent: String, + percentEncoded: Bool = false + ) { var (path, query, fragment) = decomposePathIfNeeded(pathComponent) - if path.hasSuffix("/"), urlComponents.path.hasPrefix("/") { - path.removeLast() - } else if !path.hasSuffix("/"), !urlComponents.path.hasPrefix("/") { - path += "/" - } - if percentEncoded { - urlComponents.percentEncodedPath = path + urlComponents.percentEncodedPath - } else { - urlComponents.path = path + urlComponents.path - } + if path.hasPrefix("/"), self.path.hasSuffix("/") { + path.removeFirst() + } else if !path.hasPrefix("/"), !self.path.hasSuffix("/") { + path = "/" + path + } + if percentEncoded { + percentEncodedPath += path + } else { + self.path += path + } if !query.isEmpty { addQueryItems(items: query, percentEncoded: percentEncoded) } if let fragment { - urlComponents.fragment = fragment + self.fragment = fragment } - } - + } + + public mutating func prependPath( + _ pathComponent: String, + percentEncoded: Bool = false + ) { + var (path, query, fragment) = decomposePathIfNeeded(pathComponent) + if path.hasSuffix("/"), self.path.hasPrefix("/") { + path.removeLast() + } else if !path.hasSuffix("/"), !self.path.hasPrefix("/") { + path += "/" + } + if percentEncoded { + percentEncodedPath = path + percentEncodedPath + } else { + self.path = path + self.path + } + if !query.isEmpty { + addQueryItems(items: query, percentEncoded: percentEncoded) + } + if let fragment { + self.fragment = fragment + } + } + mutating func addQueryItems( items: [URLQueryItem], percentEncoded: Bool @@ -279,27 +303,27 @@ public struct HTTPRequestComponents: Sendable, Hashable { ) } } - urlComponents.percentEncodedQueryItems = (urlComponents.percentEncodedQueryItems ?? []) + itemsToAdd + percentEncodedQueryItems = (percentEncodedQueryItems ?? []) + itemsToAdd } +} - private func decomposePathIfNeeded(_ path: String) -> (String, query: [URLQueryItem], fragment: String?) { - var path = path - var query: [URLQueryItem] = [] - var fragment: String? - if let fragmentIndex = path.lastIndex(of: "#") { - fragment = String(path[path.index(after: fragmentIndex)...]) - path = String(path[..