Xcode
Xcode

Наверняка, каждый разработчик, которому необходимо было программировать сетевой слой приложения решал задачу передачи параметров запроса. В большинстве случаев это несложная задача, которая решается стандартными средствами, которые предоставляет нативный 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 модель наконец-то можно забыть.

  1. CodingKeyRepresentable

  2. Type erasure

  3. URLComponents

  4. URLQueryItem

Комментарии (4)


  1. iStaZzzz
    23.07.2022 00:17

    Не совсем понятно в чем состоит "ненужность" моделей, тем более что их можно передавать аргументом в функцию (имхо один CreateAccountBody смотрится проще и понятней, чем перечень из 5 параметров)


  1. AceRodstin Автор
    23.07.2022 09:46

    В самом начале статьи приведен пример в виде LoginRequest модели. Обратите внимание, насколько тяжело читается эта модель. Безусловно, лучше передавать один аргумент вместо 5, но если вы работаете со словарем [String: Any], то избежите большого числа вложенных моделей. Все, что останется - объявить промежуточную модель с теми самыми 5 полями.

    В конечном итоге вы можете передавать сам словарь в качестве аргумента.


    1. iStaZzzz
      23.07.2022 10:28

      Просто что я вижу в перспективе:
      при развитии апи функция с пачкой параметров не удобна (не расширяема), а значит уже закладывается камень в фундамент будущего рефакторинга
      если же передавать словарь, то остается открытым вопрос, кто и где будет валидировать что в словаре. В случае с моделью это частично может сделать компилятор
      + магические строки в качестве ключей словаря

      В общем, словарь хорошо если нужно делать быстро, но в перспективе вижу возможные неприятности.


  1. AceRodstin Автор
    23.07.2022 12:48

    Это ваше видение и вы имеете право иметь свою точку зрения. Я же считаю, что создавать модель избыточно, тем более когда требуется разный naming в модели и json. Представьте, что бы было, если в модель LoginRequest добавить несколько CodingKey перечислений.