Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров3.1K
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. Оригинал статьи

  2. CodingKeyRepresentable

  3. Type erasure

  4. URLComponents

  5. URLQueryItem

Теги:
Хабы:
Всего голосов 2: ↑2 и ↓0+2
Комментарии4

Публикации

Работа

iOS разработчик
8 вакансий

Ближайшие события