Swift. Сериализация параметров запроса

от автора

Наверняка, каждый разработчик, которому необходимо было программировать сетевой слой приложения решал задачу передачи параметров запроса. В большинстве случаев это несложная задача, которая решается стандартными средствами, которые предоставляет нативный sdk либо язык программирования. Но если рассматривать ситуацию в контексте платформы iOS и языка программирования Swift, то тут же станет ясно, что компилятор выдает ошибку при попытке сериализации параметров в виде словаря [String: Any]. Однако, благодаря нововведениям, которые появились в iOS 15.4 и Swift 5.6 данный словарь наконец-то стало возможно сериализовать.

Задача

  • В случае передачи параметров в body запроса требуется возможность объявления в виде словаря [String: Any].

let requestParameters = [ "method": "createUser", "credentials": [       "login": login,       "password": password,       "email": email,       "fullName": [         "firstName": firstName,         "lastName": lastName       ]   ] ]

При таком подходе становится намного легче поддерживать проект, так как исключается создание «ненужных» моделей.

struct LoginRequest: Codable { struct Credentials: Codable {       struct FullName: Codable {         var firstName: String         var lastName: String       }            var login: String       var password: String       var email: String       var fullName: FullName   }      var method: String   var credentials: Credentials }

Сразу сделаем оговорку, что передавать такие параметры как логин и пароль в сетевом запросе не стоит, поскольку для этих целей существует уже устаревшая технология Basic authentication, а также более современный подход с использованием access token и refresh token.

  • В случае передачи параметров в виде query строки требуется, чтобы порядок следования при инициализации

let requestParameters = [ "email": email,   "firstName": firstName,   "lastName": lastName ]

был таким же в самой строке

email=example@example.com&firstName=Nickey&lastName=Santoro

Это требование необходимо для случаев криптования параметров (когда дополнительно передается зашифрованный 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 = Int  public var startIndex: Index { return elements.startIndex }  public var endIndex: Int { return elements.endIndex }  public subscript(position: Int) -> Element { return elements[position] }  public func index(after i: Int) -> Int { 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,     email: String,     firstName: String,     lastName: String) 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,       "email": email,       "fullName": [         "firstName": firstName,         "lastName": lastName       ]     ]   ]    try await dataRequest(     url: url,     method: .post,     headers: headers,     parameters: .body(parameters)   ) }

Во втором случае, для создания query строки запрос представлен ниже

func updateAccount(id: Int, email: String, firstName: String, lastName: String) 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,     "lastName": lastName   ]    try await dataRequest(     url: url,     method: .put,     headers: headers,     parameters: .query(parameters)   ) }

Заключение

Раньше, до появления CodingKeyRepresentable и type erasure, данное решение тоже можно было запрограммировать, только для этого нужно было дополнительно создавать контейнер AnyEncodable и проверять поле key на соответствие типу String или Int. Однако с развитием платформы намного удобней стало работать со словарем параметров и про очередную request модель наконец-то можно забыть.

  1. CodingKeyRepresentable

  2. Type erasure

  3. URLComponents

  4. URLQueryItem


ссылка на оригинал статьи https://habr.com/ru/post/678304/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *