diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 385acfaa..24001e2d 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.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 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/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) } } diff --git a/Tests/NetworkingTests/URLParametersTests.swift b/Tests/NetworkingTests/URLParametersTests.swift index 29fe2501..4cb7f484 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 = 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] { @@ -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 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 + } +}