Наверняка, каждый разработчик, которому необходимо было программировать сетевой слой приложения решал задачу передачи параметров запроса. В большинстве случаев это несложная задача, которая решается стандартными средствами, которые предоставляет нативный sdk либо язык программирования. Но если рассматривать ситуацию в контексте платформы iOS и языка программирования Swift, то тут же станет ясно, что компилятор выдает ошибку при попытке сериализации параметров в виде словаря [String: Any]. Однако, благодаря нововведениям, которые появились в iOS 15.4 и Swift 5.6 данный словарь стало существенно легче сериализовать.
Задача
В случае передачи параметров в body запроса требуется возможность объявления в виде словаря [String: Any].
let requestParameters = [
"method": "createUser",
"credentials": [
"login": login,
"password": password,
"age": age,
"notificationSettings": [
"notifyNews": isNotifyNews,
"notifyCabinet": isCabinetNotify
]
]
]
При таком подходе становится намного легче поддерживать проект, так как исключается создание "ненужных" моделей.
struct LoginRequest: Codable {
struct Credentials: Codable {
struct NotificationSettings: Codable {
var notifyNews: Bool
var notifyCabinet: Bool
}
var login: String
var password: String
var age: Int
var notificationSettings: NotificationSettings
}
var method: String
var credentials: Credentials
}
Сразу сделаем оговорку, что передавать такие параметры как логин и пароль в сетевом запросе не стоит, поскольку для этих целей существует уже устаревшая технология Basic authentication, а также более современный подход с использованием access token и refresh token.
В случае передачи параметров в виде query строки требуется, чтобы порядок следования при инициализации
let requestParameters = [
"email": email,
"firstName": firstName,
"age": age
]
был таким же в самой строке
email=example@example.com&firstName=Nickey&age=21
Это требование необходимо для случаев криптования параметров (когда дополнительно передается зашифрованный hash данной строки).
Решение
Приступим к сериализации параметров в body запроса. Благодаря протоколу CodingKeyRepresentable, появившемуся в iOS 15.4 и технике type erasure, появившейся в Swift 5.6 упростилось энкодирование Any типа.
Предположим, существует некоторый контейнер для параметров - Query, который в качестве хранилища использует массив для того, чтобы сохранить порядок следования элементов, но в то же время реализует все возможности словаря.
import Foundation
public extension Request {
struct Query<Key: Encodable> {
// MARK: - Types
public struct Parameter<K, V> {
public var key: K
public var value: V
}
// MARK: - Properties
private var elements: [Element]
// MARK: - Lifecycle
init<S: Sequence>(uniqueKeysWithValues elements: S) where S.Element == (Key, Value) {
self.elements = elements.map(Parameter.init)
}
// MARK: - Methods
public subscript(key: Key) -> Value? where Key: Equatable {
get { elements.first { $0.key == key }?.value }
set {
if let index = elements.firstIndex(where: { $0.key == key }) {
if let newValue {
elements[index].value = newValue
} else {
elements.remove(at: index)
}
} else {
if let newValue {
elements.append(Element(key: key, value: newValue))
}
}
}
}
}
}
// MARK: - Extensions
extension Request.Query: ExpressibleByDictionaryLiteral {
public typealias Value = any Encodable
public init(dictionaryLiteral elements: (Key, Value)...) {
self.elements = elements.map(Parameter.init)
}
}
extension Request.Query: RangeReplaceableCollection {
public init() {
self.elements = []
}
}
extension Request.Query: Sequence {
public typealias Iterator = IndexingIterator<Array<Element>>
public func makeIterator() -> Iterator {
return elements.makeIterator()
}
}
extension Request.Query: Collection {
public typealias Element = Parameter<Key, Value>
public typealias Index = Array<Element>.Index
public var startIndex: Index {
return elements.startIndex
}
public var endIndex: Index {
return elements.endIndex
}
public subscript(position: Index) -> Element {
return elements[position]
}
public func index(after i: Index) -> Index {
return elements.index(after: i)
}
}
Тогда, для того, чтобы воспользоваться протоколом CodingKeyRepresentable нужен будет объект, реализующий CodingKey протокол.
import Foundation
struct QueryCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init(stringValue: String) {
self.stringValue = stringValue
self.intValue = Int(stringValue)
}
init(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
init(key: CodingKeyRepresentable) {
self.stringValue = key.codingKey.stringValue
self.intValue = key.codingKey.intValue
}
}
В результате, для реализации Encodable протокола, достаточно запрограммировать метод encode(to:).
extension Request.Query: Encodable {
public func encode(to encoder: Encoder) throws {
if Key.self is CodingKeyRepresentable.Type {
var container = encoder.container(keyedBy: QueryCodingKey.self)
for element in elements {
guard let key = element.key as? CodingKeyRepresentable else {
continue
}
let codingKey = QueryCodingKey(key: key)
try container.encode(element.value, forKey: codingKey)
}
} else {
var container = encoder.unkeyedContainer()
for element in elements {
try container.encode(element.key)
try container.encode(element.value)
}
}
}
}
Далее решим задачу энкодирования в query строку. Во-первых существует несколько вариантов кодирования массива и bool значений, поэтому нужно эти варианты описать, например в виде соответствующих перечислений.
public struct QueryEncoding {
public enum ArrayEncoding {
case enclosingBrackets
case surroundingBrackets
case noBrackets
}
public enum BoolEncoding {
case numeric
case literal
}
public var array: ArrayEncoding
public var bool: BoolEncoding
public init(array: QueryEncoding.ArrayEncoding = .enclosingBrackets, bool: QueryEncoding.BoolEncoding = .literal) {
self.array = array
self.bool = bool
}
}
Далее, для того, чтобы создать query строку следует воспользоваться штатными средствами URLComponents и URLQueryItem. Таким образом, для преобразования каждого параметра в URLQueryItem достаточно объявить соответсвующий метод.
extension Request.Query where Key == String {
public func encode(to url: URL, encoding: QueryEncoding) -> URL? {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = elements.flatMap { encodeQueryItem(element: $0, encoding: encoding) }
return components?.url
}
private func encodeQueryItem(element: Element, encoding: QueryEncoding) -> [URLQueryItem] {
encodeQueryItem(name: element.key, value: element.value, encoding: encoding)
}
private func encodeQueryItem(name: String, value: Any, encoding: QueryEncoding) -> [URLQueryItem] {
switch value {
case let boolean as Bool:
let queryItem = encodeBool(name: name, value: boolean, encoding: encoding)
return [queryItem]
case let number as NSNumber:
let queryItem = encodeNumber(name: name, value: number)
return [queryItem]
case let array as [Any]:
let queryItems = encodeArray(name: name, value: array, encoding: encoding)
return queryItems
case let dictionary as [String: Any]:
let queryItems = encodeDictionary(name: name, value: dictionary, encoding: encoding)
return queryItems
default:
let queryItem = URLQueryItem(name: name, value: "\(value)")
return [queryItem]
}
}
private func encodeBool(name: String, value: Bool, encoding: QueryEncoding) -> URLQueryItem {
let stringValue: String
switch encoding.bool {
case .numeric:
stringValue = (value as NSNumber).stringValue
case .literal:
stringValue = String(value)
}
return URLQueryItem(name: name, value: stringValue)
}
private func encodeNumber(name: String, value: NSNumber) -> URLQueryItem {
let stringValue = value.stringValue
return URLQueryItem(name: name, value: stringValue)
}
private func encodeArray(name: String, value: [Any], encoding: QueryEncoding) -> [URLQueryItem] {
switch encoding.array {
case .enclosingBrackets:
return value.flatMap { encodeQueryItem(name: name + "[]", value: $0, encoding: encoding) }
case .surroundingBrackets:
let value = value
.flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) }
.compactMap { $0.value }
.map { "\"\($0)\"" }
.joined(separator: ",")
let queryItem = URLQueryItem(name: name, value: "[\(value)]")
return [queryItem]
case .noBrackets:
return value.flatMap { encodeQueryItem(name: name, value: $0, encoding: encoding) }
}
}
private func encodeDictionary(name: String, value: [String: Any], encoding: QueryEncoding) -> [URLQueryItem] {
return value
.map { encodeQueryItem(name: name + "[\($0)]", value: $1, encoding: encoding) }
.flatMap { $0 }
}
}
Внимательный читатель может спросить, для чего добавлен constraint?
where Key == String
Это ограничение обусловлено типом (String) первого поля структуры URLQueryItem.
Использование
В первом случае, когда требуется передать параметры в body запроса метод для создания и выполнения запроса будет следующим
func createAccount(login: String,
password: String,
age: Int,
isNotifyNews: Bool,
isNotifyCabinet: Bool) async throws {
let url = try createUrl(host: .staging, path: "api/v1/account/create")
let headers: [HTTPHeader] = [
.contentType("application/json"),
]
let parameters: Request.Query = [
"method": "createUser",
"credentials": [
"login": login,
"password": password,
"age": age,
"notificationSettings": [
"notifyNews": isNotifyNews,
"notifyCabinet": isNotifyCabinet
]
]
]
try await dataRequest(
url: url,
method: .post,
headers: headers,
parameters: .body(parameters)
)
}
Во втором случае, для создания query строки запрос представлен ниже
func updateAccount(id: Int, email: String, firstName: String, age: Int) async throws {
let url = try createUrl(host: .staging, path: "api/v1/account/\(id)")
let accessToken = try accessToken(for: .staging)
let headers: [HTTPHeader] = [
.authorization("Bearer \(accessToken)")
]
let parameters: Request.Query = [
"email": email,
"firstName": firstName,
"age": age
]
try await dataRequest(
url: url,
method: .put,
headers: headers,
parameters: .query(parameters)
)
}
Заключение
Раньше, до появления CodingKeyRepresentable и type erasure, данное решение тоже можно было запрограммировать, только для этого нужно было дополнительно создавать контейнер AnyEncodable и проверять поле key на соответствие типу String или Int. Однако с развитием платформы намного удобней стало работать со словарем параметров и про очередную request модель наконец-то можно забыть.
Комментарии (4)
AceRodstin Автор
23.07.2022 09:46В самом начале статьи приведен пример в виде LoginRequest модели. Обратите внимание, насколько тяжело читается эта модель. Безусловно, лучше передавать один аргумент вместо 5, но если вы работаете со словарем [String: Any], то избежите большого числа вложенных моделей. Все, что останется - объявить промежуточную модель с теми самыми 5 полями.
В конечном итоге вы можете передавать сам словарь в качестве аргумента.
iStaZzzz
23.07.2022 10:28Просто что я вижу в перспективе:
при развитии апи функция с пачкой параметров не удобна (не расширяема), а значит уже закладывается камень в фундамент будущего рефакторинга
если же передавать словарь, то остается открытым вопрос, кто и где будет валидировать что в словаре. В случае с моделью это частично может сделать компилятор
+ магические строки в качестве ключей словаря
В общем, словарь хорошо если нужно делать быстро, но в перспективе вижу возможные неприятности.
AceRodstin Автор
23.07.2022 12:48Это ваше видение и вы имеете право иметь свою точку зрения. Я же считаю, что создавать модель избыточно, тем более когда требуется разный naming в модели и json. Представьте, что бы было, если в модель LoginRequest добавить несколько CodingKey перечислений.
iStaZzzz
Не совсем понятно в чем состоит "ненужность" моделей, тем более что их можно передавать аргументом в функцию (имхо один CreateAccountBody смотрится проще и понятней, чем перечень из 5 параметров)