Skip to content

Commit

Permalink
Merge pull request #56 from strvcom/fix/55-url-encoding-and-the-+-sign
Browse files Browse the repository at this point in the history
[feat] Add support for custom URL parameters percent encoding
  • Loading branch information
cejanen authored Feb 26, 2024
2 parents 1b47e41 + 0ae7d93 commit effc8b7
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 29 deletions.
84 changes: 55 additions & 29 deletions Sources/Networking/Core/Requestable+Convenience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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>? {
HTTPStatusCode.successAndRedirectCodes
}

/// The default value is `nil`.
var dataType: RequestDataType? {
nil
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
}
35 changes: 35 additions & 0 deletions Sources/Networking/Misc/CustomEncodedParameter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 20 additions & 0 deletions Sources/Networking/Misc/URLQueryItem+PercentEncoding.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
17 changes: 17 additions & 0 deletions Sources/Networking/Utils/String+PlusSignEncoded.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
171 changes: 171 additions & 0 deletions Tests/NetworkingTests/URLParametersTests.swift
Original file line number Diff line number Diff line change
@@ -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 ?? []
}
}

0 comments on commit effc8b7

Please sign in to comment.