From 04f787d47762a8526e12cf94de75a5194f262649 Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Sun, 7 Dec 2025 13:40:05 +0400 Subject: [PATCH] feat: implement query parameters formatter --- .../QueryParametersFormatter.swift | 84 +++++++++++++++++++ .../RequestBuilder/RequestBuilder.swift | 10 ++- .../Classes/DI/NetworkLayerAssembly.swift | 9 +- .../Classes/Core/Models/IRequest.swift | 4 +- .../Models/SpecificEncodedComponent.swift | 8 ++ .../Helpers/RequestProcessor+Mock.swift | 5 +- .../Mocks/QueryParametersFormatterMock.swift | 21 +++++ .../Tests/UnitTests/RequestBuilderTests.swift | 10 ++- 8 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/QueryParametersFormatter/QueryParametersFormatter.swift create mode 100644 Sources/NetworkLayerInterfaces/Classes/Core/Models/SpecificEncodedComponent.swift create mode 100644 Tests/NetworkLayerTests/Classes/Helpers/Mocks/QueryParametersFormatterMock.swift diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/QueryParametersFormatter/QueryParametersFormatter.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/QueryParametersFormatter/QueryParametersFormatter.swift new file mode 100644 index 0000000..451ae11 --- /dev/null +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/QueryParametersFormatter/QueryParametersFormatter.swift @@ -0,0 +1,84 @@ +// +// network-layer +// Copyright © 2025 Space Code. All rights reserved. +// + +import Foundation +import NetworkLayerInterfaces + +// MARK: - IQueryParametersFormatter + +protocol IQueryParametersFormatter { + func format(rawParameters: [AnyHashable: Any]) -> [String: String] +} + +// MARK: - QueryParametersFormatter + +final class QueryParametersFormatter: IQueryParametersFormatter { + // MARK: Properties + + // Properties + private let allowedCharacters: CharacterSet = { + var restricted = CharacterSet(charactersIn: ":/?#[]@!$ &''()*+,;=\"<>%{}|\\^~`") + restricted.formUnion(.newlines) + return restricted.inverted + }() + + // MARK: Initialization + + init() {} + + // MARK: Internal + + func format(rawParameters: [AnyHashable: Any]) -> [String: String] { + var result: [String: String] = [:] + rawParameters.forEach { key, value in + guard + let encodedKey = convertKeyToEncodedString(key), + let encodedValue = convertValueToEncodedString(value) + else { + return + } + result[encodedKey] = encodedValue + } + return result + } + + // MARK: - Private + + private func convertKeyToEncodedString(_ key: AnyHashable) -> String? { + switch key { + case let string as String: + return encodeQueryComponent(string) + case let encodedComponent as SpecificEncodedComponent: + return encodedComponent.encodedValue + case let convertible as CustomStringConvertible: + return encodeQueryComponent(convertible.description) + } + } + + private func convertValueToEncodedString(_ value: Any) -> String? { + switch value { + case let string as String: + return encodeQueryComponent(string) + case is [Any], is [String: Any]: + guard + let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .prettyPrinted]), + let jsonString = String(data: data, encoding: .utf8) + else { + return nil + } + return encodeQueryComponent(jsonString) + case let encodedComponent as SpecificEncodedComponent: + return encodedComponent.encodedValue + case let convertible as CustomStringConvertible: + return encodeQueryComponent(convertible.description) + default: + return encodeQueryComponent("\(value)") + } + } + + private func encodeQueryComponent(_ component: String) -> String? { + component.addingPercentEncoding(withAllowedCharacters: allowedCharacters) + } +} diff --git a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift index 09fe07e..6fcf829 100644 --- a/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift +++ b/Sources/NetworkLayer/Classes/Core/Builders/RequestBuilder/RequestBuilder.swift @@ -1,6 +1,6 @@ // // network-layer -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -11,15 +11,18 @@ final class RequestBuilder: IRequestBuilder, @unchecked Sendable { private let parametersEncoder: IRequestParametersEncoder private let requestBodyEncoder: IRequestBodyEncoder + private let queryFormatter: IQueryParametersFormatter // MARK: Initialization init( parametersEncoder: IRequestParametersEncoder, - requestBodyEncoder: IRequestBodyEncoder + requestBodyEncoder: IRequestBodyEncoder, + queryFormatter: IQueryParametersFormatter ) { self.parametersEncoder = parametersEncoder self.requestBodyEncoder = requestBodyEncoder + self.queryFormatter = queryFormatter } // MARK: IRequestBuilder @@ -42,7 +45,8 @@ final class RequestBuilder: IRequestBuilder, @unchecked Sendable { setHeaders(to: &urlRequest, headers: request.headers) - try parametersEncoder.encode(parameters: request.parameters ?? [:], to: &urlRequest) + let parameters = queryFormatter.format(rawParameters: request.parameters ?? [:]) + try parametersEncoder.encode(parameters: parameters, to: &urlRequest) if let httpBody = request.httpBody { try requestBodyEncoder.encode(body: httpBody, to: &urlRequest) diff --git a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift index 00b5d39..119d080 100644 --- a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift +++ b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift @@ -1,6 +1,6 @@ // // network-layer -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -64,7 +64,8 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { private var requestBuilder: IRequestBuilder { RequestBuilder( parametersEncoder: parametersEncoder, - requestBodyEncoder: requestBodyEncoder + requestBodyEncoder: requestBodyEncoder, + queryFormatter: queryFormatter ) } @@ -75,4 +76,8 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { private var requestBodyEncoder: IRequestBodyEncoder { RequestBodyEncoder(jsonEncoder: jsonEncoder) } + + private var queryFormatter: IQueryParametersFormatter { + QueryParametersFormatter() + } } diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift index 2d2db89..8965623 100644 --- a/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/IRequest.swift @@ -19,7 +19,7 @@ public protocol IRequest: Sendable { var headers: [String: String]? { get } /// A dictionary that contains the parameters to be encoded into the request. - var parameters: [String: String]? { get } + var parameters: [AnyHashable: Any]? { get } /// A Boolean value indicating whether authentication is required. var requiresAuthentication: Bool { get } @@ -44,7 +44,7 @@ public extension IRequest { } /// A dictionary that contains the parameters to be encoded into the request. - var parameters: [String: String]? { + var parameters: [AnyHashable: Any]? { nil } diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/SpecificEncodedComponent.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/SpecificEncodedComponent.swift new file mode 100644 index 0000000..0ae15c7 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/SpecificEncodedComponent.swift @@ -0,0 +1,8 @@ +// +// network-layer +// Copyright © 2025 Space Code. All rights reserved. +// + +public protocol SpecificEncodedComponent { + var encodedValue: String { get } +} diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift index 39a82d0..c86ae0b 100644 --- a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift +++ b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift @@ -1,6 +1,6 @@ // // network-layer -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // import Foundation @@ -23,7 +23,8 @@ extension RequestProcessor { ), requestBuilder: RequestBuilder( parametersEncoder: RequestParametersEncoder(), - requestBodyEncoder: RequestBodyEncoder(jsonEncoder: JSONEncoder()) + requestBodyEncoder: RequestBodyEncoder(jsonEncoder: JSONEncoder()), + queryFormatter: QueryParametersFormatter() ), dataRequestHandler: DataRequestHandler(), retryPolicyService: RetryPolicyService(strategy: .constant(retry: 1, duration: .seconds(0))), diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/QueryParametersFormatterMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/QueryParametersFormatterMock.swift new file mode 100644 index 0000000..bb015b1 --- /dev/null +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/QueryParametersFormatterMock.swift @@ -0,0 +1,21 @@ +// +// network-layer +// Copyright © 2025 Space Code. All rights reserved. +// + +@testable import NetworkLayer + +final class QueryParametersFormatterMock: IQueryParametersFormatter { + var invokedFormat = false + var invokedFormatCount = 0 + var invokedFormatParameters: ([AnyHashable: Any], Void)? + var invokedFormatParametersList = [([AnyHashable: Any], Void)]() + var stubbedFormat: [String: String]! + func format(rawParameters: [AnyHashable: Any]) -> [String: String] { + invokedFormat = true + invokedFormatCount += 1 + invokedFormatParameters = (rawParameters, ()) + invokedFormatParametersList.append((rawParameters, ())) + return stubbedFormat + } +} diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift index 2def897..f8577dd 100644 --- a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestBuilderTests.swift @@ -1,6 +1,6 @@ // // network-layer -// Copyright © 2023 Space Code. All rights reserved. +// Copyright © 2024 Space Code. All rights reserved. // @testable import NetworkLayer @@ -13,6 +13,7 @@ final class RequestBuilderTests: XCTestCase { private var parametersEncoderMock: RequestParametersEncoderMock! private var requestBodyEncoderMock: RequestBodyEncoderMock! + private var queryParametersFormatterMock: QueryParametersFormatterMock! private var sut: RequestBuilder! @@ -22,9 +23,11 @@ final class RequestBuilderTests: XCTestCase { super.setUp() parametersEncoderMock = RequestParametersEncoderMock() requestBodyEncoderMock = RequestBodyEncoderMock() + queryParametersFormatterMock = QueryParametersFormatterMock() sut = RequestBuilder( parametersEncoder: parametersEncoderMock, - requestBodyEncoder: requestBodyEncoderMock + requestBodyEncoder: requestBodyEncoderMock, + queryFormatter: queryParametersFormatterMock ) } @@ -32,6 +35,7 @@ final class RequestBuilderTests: XCTestCase { parametersEncoderMock = nil requestBodyEncoderMock = nil sut = nil + queryParametersFormatterMock = nil super.tearDown() } @@ -62,6 +66,8 @@ final class RequestBuilderTests: XCTestCase { requestStub.stubbedHttpBody = .dictionary(.item) requestStub.stubbedParameters = .contentType + queryParametersFormatterMock.stubbedFormat = .contentType + // when var invokedConfigure = false let request = try sut.build(requestStub) { _ in invokedConfigure = true }