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
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ jobs:
fail-fast: false
matrix:
include:
- name: 'iOS 18.2, Xcode 16.2'
destination: 'OS=18.2,name=iPhone 16 Pro'
xcode: 'Xcode_16.2'
runsOn: macos-15
- name: 'macOS 15, Xcode 16.2'
- name: 'iOS 26.2, Xcode 26.2'
destination: 'OS=26.2,name=iPhone 17 Pro'
xcode: 'Xcode_26.2'
runsOn: macos-26
- name: 'macOS 26, Xcode 26.2'
destination: 'platform=macOS'
xcode: 'Xcode_16.2'
runsOn: macos-15
xcode: 'Xcode_26.2'
runsOn: macos-26
steps:
- uses: actions/checkout@v4

Expand Down
34 changes: 29 additions & 5 deletions Sources/Networking/Core/Requestable+Convenience.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,37 @@ private extension Requestable {
return [URLQueryItem(name: key, value: parameter.encodedValue)]

default:
return [
URLQueryItem(name: key, value: String(describing: value))
.percentEncoded()
]
if let stringValue = stringValue(value) {
return [
URLQueryItem(name: key, value: stringValue)
.percentEncoded()
]
} else {
return []
}
}
}


// Since `Any` type can be of type `Optional` under the hood, we want to make sure that the resulting string won't be encoded as "Optional({value})", because that's the default result of passing an `Optional` type to `String(describing: )` init.
//
// Example:
// let optionalInt: Int? = 10
// String(describing: optionalInt) == "Optional(10)"
// stringValue(optionalInt) == "10"
func stringValue(_ value: Any) -> String? {
let mirror = Mirror(reflecting: value)

if mirror.displayStyle == .optional {
if let first = mirror.children.first {
return String(describing: first.value)
} else {
return nil
}
}

return String(describing: value)
}

func buildArrayParameter(
key: String,
parameter: ArrayParameter
Expand Down
2 changes: 1 addition & 1 deletion Tests/NetworkingTests/DownloadAPIManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ final class DownloadAPIManagerTests: XCTestCase {
}
}

wait(for: [expectation], timeout: 5)
wait(for: [expectation], timeout: 10)
}
}
172 changes: 144 additions & 28 deletions Tests/NetworkingTests/URLParametersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,6 @@ import XCTest
private let baseURLString = "https://requestable.tests"

final class URLParametersTests: XCTestCase {
enum Router: Requestable {
case urlParameters([String: any Sendable])

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 keyString = "name[first]"
let keyPercentEncodedString = "name%5Bfirst%5D"
Expand All @@ -48,7 +28,7 @@ final class URLParametersTests: XCTestCase {
let queryItems = percentEncodedQueryItems(from: url)

XCTAssertEqual(
queryItems.first(where: { $0.name == keyPercentEncodedString })?.value,
queryItems.value(for: keyPercentEncodedString),
valuePercentEncodedString
)
}
Expand All @@ -65,7 +45,7 @@ final class URLParametersTests: XCTestCase {

let queryItems = percentEncodedQueryItems(from: url)
XCTAssertEqual(
queryItems.first(where: { $0.name == "date" })?.value,
queryItems.value(for: "date"),
dateString
)
}
Expand All @@ -83,7 +63,7 @@ final class URLParametersTests: XCTestCase {

let queryItems = percentEncodedQueryItems(from: url)
XCTAssertEqual(
queryItems.first(where: { $0.name == "date" })?.value,
queryItems.value(for: "date"),
datePlusSignPercentEncodedString
)
}
Expand All @@ -106,12 +86,12 @@ final class URLParametersTests: XCTestCase {

let queryItems = percentEncodedQueryItems(from: url)
XCTAssertEqual(
queryItems.first(where: { $0.name == "date" })?.value,
queryItems.value(for: "date"),
datePlusSignPercentEncodedString
)

XCTAssertEqual(
queryItems.first(where: { $0.name == "search" })?.value,
queryItems.value(for: "search"),
searchString
)
}
Expand All @@ -135,12 +115,12 @@ final class URLParametersTests: XCTestCase {

let queryItems = percentEncodedQueryItems(from: url)
XCTAssertEqual(
queryItems.first(where: { $0.name == "date" })?.value,
queryItems.value(for: "date"),
datePlusSignPercentEncodedString
)

XCTAssertEqual(
queryItems.first(where: { $0.name == "search" })?.value,
queryItems.value(for: "search"),
searchPercentEncodedString
)
}
Expand All @@ -159,16 +139,152 @@ final class URLParametersTests: XCTestCase {

let queryItems = percentEncodedQueryItems(from: url)
XCTAssertEqual(
queryItems.first(where: { $0.name == "date" })?.value,
queryItems.value(for: "date"),
customPercentEncodedString
)
}

func testOptionalsParametersEncodingWithValues() async throws {
let parameters = OptionalParameters(
int: 10,
string: "testString",
stringsArray: ["1", "2"]
)
let router = OptionalParametersRouter.test(parameters)

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.value(for: OptionalParameters.CodingKeys.int.stringValue),
"10"
)
XCTAssertEqual(
queryItems.value(for: OptionalParameters.CodingKeys.string.stringValue),
"testString"
)
XCTAssertEqual(
queryItems.value(for: OptionalParameters.CodingKeys.stringsArray.stringValue),
"1,2"
)
}

func testOptionalsParametersEncodingWithNils() async throws {
let parameters = OptionalParameters(
int: 10,
string: nil,
stringsArray: nil
)
let router = OptionalParametersRouter.test(parameters)

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.value(for: OptionalParameters.CodingKeys.int.stringValue),
"10"
)
XCTAssertEqual(
queryItems.value(for: OptionalParameters.CodingKeys.string.stringValue),
nil
)
XCTAssertEqual(
queryItems.value(for: OptionalParameters.CodingKeys.stringsArray.stringValue),
nil
)
}
}

// MARK: Helpers
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 ?? []
}
}

private extension [URLQueryItem] {
func value(for key: String) -> String? {
first(where: { $0.name == key })?.value
}
}

// MARK: Routers
private enum Router: Requestable {
case urlParameters([String: any Sendable])

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
.compactMapValues { $0 }
}
}
}

private struct OptionalParameters: Codable {
enum CodingKeys: CodingKey {
case int
case string
case stringsArray
}

let int: Int?
let string: String?
let stringsArray: [String]?
}

private enum OptionalParametersRouter: Requestable {
case test(OptionalParameters)

var baseURL: URL {
// swiftlint:disable:next force_unwrapping
URL(string: baseURLString)!
}

var path: String {
""
}

var urlParameters: [String: Any]? {
switch self {
case let .test(params):
var urlParameters: [String: Any] = [
OptionalParameters.CodingKeys.int.stringValue: params.int as Any,
OptionalParameters.CodingKeys.string.stringValue: params.string as Any
]

if let stringsArray = params.stringsArray {
urlParameters[OptionalParameters.CodingKeys.stringsArray.stringValue] = ArrayParameter(stringsArray, arrayEncoding: .commaSeparated)
}

return urlParameters
}
}

var method: HTTPMethod {
.get
}
}