diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index a7cab3d3..1e3bd543 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -15,27 +15,27 @@ public extension Requestable { var method: HTTPMethod { .get } - + /// By default the requestable API endpoint is unauthenticated. var isAuthenticationRequired: Bool { false } - + /// The default value is `nil`. var headers: [String: String]? { nil } - + /// The default value is `nil`. var urlParameters: [String: Any]? { nil } - + /// The default value is success & redirect http codes 200-399. var acceptableStatusCodes: Range? { HTTPStatusCode.successAndRedirectCodes } - + /// The default value is `nil`. var dataType: RequestDataType? { nil @@ -53,10 +53,10 @@ public extension Requestable { guard var urlComponents = URLComponents(url: urlPath, resolvingAgainstBaseURL: true) else { throw RequestableError.invalidURLComponents } - + // encode url parameters if let urlParameters { - urlComponents.queryItems = buildQueryItems(urlParameters: urlParameters) + urlComponents.percentEncodedQueryItems = buildPercentEncodedQueryItems(urlParameters: urlParameters) } return urlComponents @@ -74,12 +74,12 @@ public extension Requestable { return data } } - + func asRequest() throws -> URLRequest { guard let url = try urlComponents().url else { throw RequestableError.invalidURLComponents } - + // request setup var request = URLRequest(url: url) request.httpMethod = method.rawValue @@ -101,43 +101,69 @@ public extension Requestable { default: break } - + return request } } // MARK: Build Query Items private extension Requestable { - func buildQueryItems(urlParameters: [String: Any]) -> [URLQueryItem] { + func buildPercentEncodedQueryItems(urlParameters: [String: Any]) -> [URLQueryItem] { urlParameters .map { key, value -> [URLQueryItem] in - buildQueryItems(key: key, value: value) + buildPercentEncodedQueryItem(key: key, value: value) } .flatMap { $0 } } - func buildQueryItems(key: String, value: Any) -> [URLQueryItem] { - if let arrayType = value as? ArrayParameter { - var queryItems: [URLQueryItem] = [] + func buildPercentEncodedQueryItem(key: String, value: Any) -> [URLQueryItem] { + switch value { + case let parameter as ArrayParameter: + return buildArrayParameter( + key: key, + parameter: parameter + ) + + case let parameter as CustomEncodedParameter: + return [URLQueryItem(name: key, value: parameter.encodedValue)] - switch arrayType.arrayEncoding { - case .commaSeparated: - queryItems = [URLQueryItem( + default: + return [ + URLQueryItem(name: key, value: String(describing: value)) + .percentEncoded() + ] + } + } + + func buildArrayParameter( + key: String, + parameter: ArrayParameter + ) -> [URLQueryItem] { + var queryItems: [URLQueryItem] = [] + + switch parameter.arrayEncoding { + case .commaSeparated: + queryItems = [ + URLQueryItem( name: key, - value: arrayType.values.map { String(describing: $0) }.joined(separator: ",") - )] - - case .individual: - for parameter in arrayType.values { - queryItems.append(URLQueryItem( + value: parameter.values + .map { String(describing: $0) } + .joined(separator: ",") + ) + .percentEncoded() + ] + + case .individual: + for parameter in parameter.values { + queryItems.append( + URLQueryItem( name: key, value: String(describing: parameter) - )) - } + ) + .percentEncoded() + ) } - return queryItems } - - return [URLQueryItem(name: key, value: String(describing: value))] + return queryItems } } diff --git a/Sources/Networking/Misc/CustomEncodedParameter.swift b/Sources/Networking/Misc/CustomEncodedParameter.swift new file mode 100644 index 00000000..372b3775 --- /dev/null +++ b/Sources/Networking/Misc/CustomEncodedParameter.swift @@ -0,0 +1,35 @@ +// +// CustomEncodedParameter.swift +// +// +// Created by Matej Molnár on 01.01.2024. +// + +import Foundation + +/// URL request query parameter that represents a value which will not be subjected to default percent encoding during URLRequest construction. +/// +/// This type is useful in case you want to override the default percent encoding of some special characters with accordance to RFC3986. +/// +/// Usage example: +/// +/// var urlParameters: [String: Any]? { +/// ["specialCharacter": ">"] +/// } +/// +/// // Request URL "https://test.com?specialCharacter=%3E" +/// +/// var urlParameters: [String: Any]? { +/// ["specialCharacter": PercentEncodedParameter(">")] +/// } +/// +/// // Request URL "https://test.com?specialCharacter=>" +/// + +public struct CustomEncodedParameter { + let encodedValue: String + + public init(_ encodedValue: String) { + self.encodedValue = encodedValue + } +} diff --git a/Sources/Networking/Misc/URLQueryItem+PercentEncoding.swift b/Sources/Networking/Misc/URLQueryItem+PercentEncoding.swift new file mode 100644 index 00000000..27577fe8 --- /dev/null +++ b/Sources/Networking/Misc/URLQueryItem+PercentEncoding.swift @@ -0,0 +1,20 @@ +// +// URLQueryItem+PercentEncoding.swift +// +// +// Created by Tomas Cejka on 02.01.2024. +// + +import Foundation + +/// Convenience methods to provide custom percent encoding for URLQueryItem +extension URLQueryItem { + + func percentEncoded() -> URLQueryItem { + var newQueryItem = self + newQueryItem.value = value? + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + + return newQueryItem + } +} diff --git a/Sources/Networking/Utils/String+PlusSignEncoded.swift b/Sources/Networking/Utils/String+PlusSignEncoded.swift new file mode 100644 index 00000000..4c2553a3 --- /dev/null +++ b/Sources/Networking/Utils/String+PlusSignEncoded.swift @@ -0,0 +1,17 @@ +// +// String+PlusSignEncoded.swift +// +// +// Created by Tomas Cejka on 17.02.2024. +// + +import Foundation + +public extension String { + /// Help method to allow custom + sign encoding, more in ```CustomEncodedParameter``` + func plusSignEncoded() -> Self? { + self + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)? + .replacingOccurrences(of: "+", with: "%2B") + } +} diff --git a/Tests/NetworkingTests/URLParametersTests.swift b/Tests/NetworkingTests/URLParametersTests.swift new file mode 100644 index 00000000..5b6244c1 --- /dev/null +++ b/Tests/NetworkingTests/URLParametersTests.swift @@ -0,0 +1,171 @@ +// +// URLParametersTests.swift +// +// +// Created by Matej Molnár on 02.01.2024. +// + +import Networking +import XCTest + +private let baseURLString = "https://requestable.tests" + +final class URLParametersTests: XCTestCase { + enum Router: Requestable { + case urlParameters([String: Any]) + + var baseURL: URL { + // swiftlint:disable:next force_unwrapping + URL(string: baseURLString)! + } + + var path: String { + "" + } + + var urlParameters: [String: Any]? { + switch self { + case let .urlParameters(parameters): + parameters + } + } + } + + func testDefaultEncoding() async throws { + let nameString = "name]surname" + let namePercentEncodedString = "name%5Dsurname" + + let router = Router.urlParameters(["name": nameString]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "name" })?.value, + namePercentEncodedString + ) + } + + func testPlusSignDefaultEncoding() async throws { + let dateString = "2023-11-29T12:13:04.598+0100" + let router = Router.urlParameters(["date": dateString]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "date" })?.value, + dateString + ) + } + + func testPlusSignPercentEncodedParameter() async throws { + let dateString = "2023-11-29T12:13:04.598+0100" + let datePlusSignPercentEncodedString = "2023-11-29T12:13:04.598%2B0100" + let router = Router.urlParameters(["date": CustomEncodedParameter(dateString.plusSignEncoded() ?? "")]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "date" })?.value, + datePlusSignPercentEncodedString + ) + } + + func testMixedPlusSignPercentEncodedParameter() async throws { + let dateString = "2023-11-29T12:13:04.598+0100" + let datePlusSignPercentEncodedString = "2023-11-29T12:13:04.598%2B0100" + let searchString = "name+surname" + + let router = Router.urlParameters([ + "date": CustomEncodedParameter(dateString.plusSignEncoded() ?? ""), + "search": searchString + ]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "date" })?.value, + datePlusSignPercentEncodedString + ) + + XCTAssertEqual( + queryItems.first(where: { $0.name == "search" })?.value, + searchString + ) + } + + func testMixedPercentEncodedParameter() async throws { + let dateString = "2023-11-29T12:13:04.598+0100" + let datePlusSignPercentEncodedString = "2023-11-29T12:13:04.598%2B0100" + let searchString = "name+surnam]e" + let searchPercentEncodedString = "name+surnam%5De" + + let router = Router.urlParameters([ + "date": CustomEncodedParameter(dateString.plusSignEncoded() ?? ""), + "search": searchString + ]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "date" })?.value, + datePlusSignPercentEncodedString + ) + + XCTAssertEqual( + queryItems.first(where: { $0.name == "search" })?.value, + searchPercentEncodedString + ) + } + + func testCustomPercentEncodedParameter() async throws { + let customPercentEncodedString = "2023-11-29T12:13:04.598%2B+%0100" + let router = Router.urlParameters([ + "date": CustomEncodedParameter(customPercentEncodedString) + ]) + let request = try router.asRequest() + + guard let url = request.url else { + XCTFail("Can't create url from router") + return + } + + let queryItems = percentEncodedQueryItems(from: url) + XCTAssertEqual( + queryItems.first(where: { $0.name == "date" })?.value, + customPercentEncodedString + ) + } +} + +private extension URLParametersTests { + // Helper method to create query items from URL to compare it with expected percent encoding + func percentEncodedQueryItems(from: URL) -> [URLQueryItem] { + let urlComponents = URLComponents(url: from, resolvingAgainstBaseURL: true) + return urlComponents?.percentEncodedQueryItems ?? [] + } +}