From 973083de5ccb7d5586fc76b036a0c040e3880e7b Mon Sep 17 00:00:00 2001 From: Matej Molnar Date: Mon, 12 Jan 2026 17:37:23 +0100 Subject: [PATCH 1/5] fix: optional url parameter encoding and add tests --- .../Core/Requestable+Convenience.swift | 34 +++- .../NetworkingTests/URLParametersTests.swift | 172 +++++++++++++++--- 2 files changed, 173 insertions(+), 33 deletions(-) diff --git a/Sources/Networking/Core/Requestable+Convenience.swift b/Sources/Networking/Core/Requestable+Convenience.swift index 10a5c2c6..d8182284 100644 --- a/Sources/Networking/Core/Requestable+Convenience.swift +++ b/Sources/Networking/Core/Requestable+Convenience.swift @@ -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 diff --git a/Tests/NetworkingTests/URLParametersTests.swift b/Tests/NetworkingTests/URLParametersTests.swift index 29fe2501..b0ba59da 100644 --- a/Tests/NetworkingTests/URLParametersTests.swift +++ b/Tests/NetworkingTests/URLParametersTests.swift @@ -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" @@ -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 ) } @@ -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 ) } @@ -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 ) } @@ -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 ) } @@ -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 ) } @@ -159,12 +139,75 @@ 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 = OptionalParametersRouter.Parameters( + 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: OptionalParametersRouter.Parameters.CodingKeys.int.stringValue), + "10" + ) + XCTAssertEqual( + queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.string.stringValue), + "testString" + ) + XCTAssertEqual( + queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.stringsArray.stringValue), + "1,2" + ) + } + + func testOptionalsParametersEncodingWithNils() async throws { + let parameters = OptionalParametersRouter.Parameters( + 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: OptionalParametersRouter.Parameters.CodingKeys.int.stringValue), + "10" + ) + XCTAssertEqual( + queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.string.stringValue), + nil + ) + XCTAssertEqual( + queryItems.value(for: OptionalParametersRouter.Parameters.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] { @@ -172,3 +215,76 @@ private extension URLParametersTests { 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 enum OptionalParametersRouter: Requestable { + struct Parameters: Codable { + enum CodingKeys: CodingKey { + case int + case string + case stringsArray + } + + let int: Int? + let string: String? + let stringsArray: [String]? + } + + case test(Parameters) + + 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] = [ + Parameters.CodingKeys.int.stringValue: params.int as Any, + Parameters.CodingKeys.string.stringValue: params.string as Any + ] + + if let stringsArray = params.stringsArray { + urlParameters[Parameters.CodingKeys.stringsArray.stringValue] = ArrayParameter(stringsArray, arrayEncoding: .commaSeparated) + } + + return urlParameters.compactMapValues { $0 } + } + } + + var method: HTTPMethod { + .get + } +} From 3733a190c3a8d2d0109319531e9e141037e1d0f1 Mon Sep 17 00:00:00 2001 From: Matej Molnar Date: Wed, 14 Jan 2026 10:23:47 +0100 Subject: [PATCH 2/5] fix: swiftlint warning --- .../NetworkingTests/URLParametersTests.swift | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/Tests/NetworkingTests/URLParametersTests.swift b/Tests/NetworkingTests/URLParametersTests.swift index b0ba59da..4cb7f484 100644 --- a/Tests/NetworkingTests/URLParametersTests.swift +++ b/Tests/NetworkingTests/URLParametersTests.swift @@ -145,7 +145,7 @@ final class URLParametersTests: XCTestCase { } func testOptionalsParametersEncodingWithValues() async throws { - let parameters = OptionalParametersRouter.Parameters( + let parameters = OptionalParameters( int: 10, string: "testString", stringsArray: ["1", "2"] @@ -162,21 +162,21 @@ final class URLParametersTests: XCTestCase { let queryItems = percentEncodedQueryItems(from: url) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.int.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.int.stringValue), "10" ) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.string.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.string.stringValue), "testString" ) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.stringsArray.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.stringsArray.stringValue), "1,2" ) } func testOptionalsParametersEncodingWithNils() async throws { - let parameters = OptionalParametersRouter.Parameters( + let parameters = OptionalParameters( int: 10, string: nil, stringsArray: nil @@ -193,15 +193,15 @@ final class URLParametersTests: XCTestCase { let queryItems = percentEncodedQueryItems(from: url) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.int.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.int.stringValue), "10" ) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.string.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.string.stringValue), nil ) XCTAssertEqual( - queryItems.value(for: OptionalParametersRouter.Parameters.CodingKeys.stringsArray.stringValue), + queryItems.value(for: OptionalParameters.CodingKeys.stringsArray.stringValue), nil ) } @@ -244,20 +244,20 @@ private enum Router: Requestable { } } -private enum OptionalParametersRouter: Requestable { - struct Parameters: Codable { - enum CodingKeys: CodingKey { - case int - case string - case stringsArray - } - - let int: Int? - let string: String? - let stringsArray: [String]? +private struct OptionalParameters: Codable { + enum CodingKeys: CodingKey { + case int + case string + case stringsArray } - case test(Parameters) + let int: Int? + let string: String? + let stringsArray: [String]? +} + +private enum OptionalParametersRouter: Requestable { + case test(OptionalParameters) var baseURL: URL { // swiftlint:disable:next force_unwrapping @@ -272,15 +272,15 @@ private enum OptionalParametersRouter: Requestable { switch self { case let .test(params): var urlParameters: [String: Any] = [ - Parameters.CodingKeys.int.stringValue: params.int as Any, - Parameters.CodingKeys.string.stringValue: params.string as Any + OptionalParameters.CodingKeys.int.stringValue: params.int as Any, + OptionalParameters.CodingKeys.string.stringValue: params.string as Any ] if let stringsArray = params.stringsArray { - urlParameters[Parameters.CodingKeys.stringsArray.stringValue] = ArrayParameter(stringsArray, arrayEncoding: .commaSeparated) + urlParameters[OptionalParameters.CodingKeys.stringsArray.stringValue] = ArrayParameter(stringsArray, arrayEncoding: .commaSeparated) } - return urlParameters.compactMapValues { $0 } + return urlParameters } } From e117064c38874a41e192c72de2561a50a7add12b Mon Sep 17 00:00:00 2001 From: Matej Molnar Date: Fri, 16 Jan 2026 11:19:54 +0100 Subject: [PATCH 3/5] chore: bump ci os versions --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 385acfaa..056132c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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.1, Xcode 26.2' + destination: 'OS=26.1,name=iPhone 16 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 From 440f1e0e0a8536ff5d127103278e4f889d3e8234 Mon Sep 17 00:00:00 2001 From: Matej Molnar Date: Fri, 16 Jan 2026 11:25:39 +0100 Subject: [PATCH 4/5] chore: bump ci ios version --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 056132c8..24001e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: fail-fast: false matrix: include: - - name: 'iOS 26.1, Xcode 26.2' - destination: 'OS=26.1,name=iPhone 16 Pro' + - 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' From 69cb5afd50610c5717fc6d4c590bf079b912f3a3 Mon Sep 17 00:00:00 2001 From: Matej Molnar Date: Fri, 16 Jan 2026 13:00:43 +0100 Subject: [PATCH 5/5] chore: increase test timeout --- Tests/NetworkingTests/DownloadAPIManagerTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NetworkingTests/DownloadAPIManagerTests.swift b/Tests/NetworkingTests/DownloadAPIManagerTests.swift index 54f3f396..9530a299 100644 --- a/Tests/NetworkingTests/DownloadAPIManagerTests.swift +++ b/Tests/NetworkingTests/DownloadAPIManagerTests.swift @@ -65,6 +65,6 @@ final class DownloadAPIManagerTests: XCTestCase { } } - wait(for: [expectation], timeout: 5) + wait(for: [expectation], timeout: 10) } }