Codable для API запросов и как навести в коде порядок

от автора

Привет, Хабр!

Начиная со Swift 4 нам доступен новый протокол Codable, который позволяет легко кодировать/декодировать модели. В моих проектах очень много кода для API вызовов, и за последний год я проделал большую работу по оптимизации этого огромного массива кода во что-то очень легкое, лаконичное и простое путем убивания повторяющегося кода и использования Codable даже для multipart запросов и url query параметров. Так получилось несколько отличных на мой взгляд классов для отправки запросов и парсинга ответов от сервера. А также удобная структура файлов представляющая из себя контроллеры для каждой группы запросов, которая мне привилась при использовании Vapor 3 на бэкенде. Несколько дней назад я выделил все свои наработки в отдельную библиотеку и назвал ее CodyFire. О ней мне и хотелось бы рассказать в этой статье.

Дисклеймер

CodyFire базируется на Alamofire, но это несколько больше чем просто обертка над Alamofire, это целый системный подход к работе с REST API для iOS. Именно поэтому я не переживаю, что в Alamofire пилят пятую версию в которой будет поддержка Codable, т.к. это не убьет моё творение.

Инициализация

Начнем немного издалека, а именно с того, что часто мы имеем три сервера:

dev — для разработки, то что запускаем из Xcode
stage — для тестирования перед релизом, обычно в TestFlight или InHouse
prod — продакшн, для AppStore

И многие iOS разработчики конечно же знают о существовании Environment Variables и о схемах запуска в Xcode, но за мою (8+ лет) практику 90% разработчиков руками прописывают нужный сервер в какой-нибудь константе пока тестируют, или перед сборкой, и это то, что мне хотелось бы исправить показав хороший пример как надо делать правильно.

CodyFire по умолчанию автоматически определяет в каком окружении сейчас запущено приложение, делает оно это очень просто:

#if DEBUG     //DEV environment #else     if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" {         //TESTFLIGHT environment     } else {         //APPSTORE environment     } #endif 

Это конечно же под капотом, а в проекте в AppDelegate вам нужно всего лишь прописать три URL

import CodyFire  @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate {     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {         let dev = CodyFireEnvironment(baseURL: "http://localhost:8080")         let testFlight = CodyFireEnvironment(baseURL: "https://stage.myapi.com")         let appStore = CodyFireEnvironment(baseURL: "https://api.myapi.com")         CodyFire.shared.configureEnvironments(dev: dev,                                                testFlight: testFlight,                                               appStore: appStore)         return true     } } 

И можно было бы просто этому порадоваться и больше ничего не делать.

Но в реальной жизни нам часто нужно в Xcode тестировать dev, stage и prod сервера, и для этого я призываю использовать схемы запуска.

image

Совет: в разделе Manage schemes не забудьте каждой схеме поставить галочку `shared` чтобы они были доступны всем разработчикам в проекте.

В каждой схеме нужно прописать переменную окружения `env` которая может принимать три значения: dev, testFlight, appStore.

image

И чтобы эти схемы заработали с CodyFire нужно добавить следующий код в AppDelegate.didFinishLaunchingWithOptions после инициализации CodyFire

CodyFire.shared.setupEnvByProjectScheme()

Более того, часто босс или тестировщики вашего проекта могут просить о переключении сервера «на лету» где-нибудь на LoginScreen. С CodyFire вы сможете легко это реализовать переключая сервер одной строкой изменив окружение:

CodyFire.shared.environmentMode = .appStore

Это будет работать до перезапуска приложения, а если нужно чтобы и после запуска сохранялось, то сохраните значение в UserDefaults, делайте проверку при запуске приложения в AppDelegate и переключайте окружение на необходимое.

Этот важный момент я рассказал, надеюсь, что будет больше проектов в которых переключение окружения будет сделано красиво. А заодно мы уже и инициализировали библиотеку.

Структура файлов и контроллеры

Теперь можно рассказать о моем видении структуры файлов для всех API вызовов, это можно назвать идеологией CodyFire.

Давайте сразу посмотрим как это в итоге выглядит в проекте

image

А теперь посмотрим листинги файлов, начнем с API.swift.

class API {     typealias auth = AuthController     typealias post = PostController } 

Здесь перечислены ссылки на все контроллеры, чтобы их было легко вызывать через `API.controller.method`.

class AuthController {} 

API+Login.swift

extension AuthController {     struct LoginResponse: Codable {         var token: String     }          static func login(email: String, password: String) -> APIRequest<LoginResponse> {         return APIRequest("login").method(.post)                                   .basicAuth(email: email, password: password)                                   .addKnownError(.notFound, "User not found")     } } 

В этом декораторе мы декларируем функцию обращения к нашему API:

— указываем endpoint
— HTTP метод POST
— используем враппер для basic auth
— деклариируем желаемый текст для определенного ответа от сервера (это удобно)
— и указываем модель по которой будут декодированы данные

Что осталось скрытым?

— не нужно указывать полный URL сервера, т.к. он уже задан глобально
— не пришлось указывать, что мы ожидаем получить 200 OK если все хорошо

200 ОК это статус код по умолчанию ожидаемый CodyFire для всех запросов, в случае которого идет декодинг данных и вызывается callback, что всё хорошо, вот ваши данные.

Далее где-то в коде для вашего LoginScreen вы сможете просто вызывать

API.auth.login(email: "test@mail.com", password: "qwerty").onKnownError { error in     switch error.code {     case .notFound: print(error.description) //выведет: User not found     default: print(error.description)     } }.onSuccess { token in     //TODO: сохраняем auth token в надежном месте     print("Received auth token: "+ token) } 

onKnownError и onSuccess это только малая часть колбэков, которые может возвращать APIRequest, поговорим о них позже.

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

API+Signup.swift

extension AuthController {     struct SignupRequest: JSONPayload {         let email, password: String         let firstName, lastName, mobileNumber: String         init(email: String,              password: String,              firstName: String,               lastName: String,               mobileNumber: String) {             self.email = email             self.password = password             self.firstName = firstName             self.lastName = lastName             self.mobileNumber = mobileNumber         }     }          struct SignupResponse: Codable {         let token: String     }          static func signup(_ request: SignupRequest) -> APIRequest<SignupResponse> {         return APIRequest("signup", payload: request).method(.post)                 .addKnownError(.conflict, "Account already exists")     } } 

В отличие от входа, при регистрации мы передаем большое количество данных.

В данном примере мы имеем модель SignupRequest которая соответствует протоколу JSONPayload (таким образом CodyFire понимает тип payload), чтобы body нашего запроса было в виде JSON.

В итоге вы получаете простую функцию которая принимает модель payload

API.auth.signup(request)

и которая в случае успеха вернет вам определенную модель ответа.

По-моему уже круто, да?

А что если multipart?

Давайте рассмотрим пример когда можно создать некий Post.

Post+Create.swift

extension PostController {     struct CreateRequest: MultipartPayload {         var text: String         var tags: [String]         var images: [Attachment]         var video: Data         init (text: String, tags: [String], images: [Attachment], video: Data) {             self.text = text             self.tags = tags             self.images = images             self.video = video         }     }          struct Post: Codable {         let text: String         let tags: [String]         let linksToImages: [String]         let linkToVideo: String     }          static func create(_ request: CreateRequest) -> APIRequest<CreateRequest> {         return APIRequest("post", payload: request).method(.post)     } } 

Данный код сможет отправить multipart форму с массивом файлов картинок и с одним видео.
Посмотрим как вызвать отправку. Тут самый интересный момент про Attachment.

let videoData = FileManager.default.contents(atPath: "/path/to/video.mp4")! let imageAttachment = Attachment(data: UIImage(named: "cat")!.jpeg(.high)!,                                   fileName: "cat.jpg",                                  mimeType: .jpg) let payload = PostController.CreateRequest(text: "CodyFire is awesome",                                             tags: ["codyfire", "awesome"],                                            images: [imageAttachment],                                            video: videoData) API.post.create(payload).onProgress { progress in     print("прогресс выгрузки: \(progress)") }.onKnownError { error in     print(error.description) }.onSuccess { createdPost in     print("пост успешно создан: \(createdPost)") } 

Attachment это модель в которой помимо Data передается также имя файла и его MimeType.

Если вы хоть раз отправляли multipart форму из Swift с использованием Alamofire или голого URLRequest я уверен вы оцените простоту CodyFire.

Теперь более простые, но не менее классные примеры GET вызовов.

Post+Get.swift

extension PostController {     struct ListQuery: Codable {         let offset, limit: Int         init (offset: Int, limit: Int) {             self.offset = offset             self.limit = limit         }     }          static func get(_ query: ListQuery? = nil) -> APIRequest<[Post]> {         return APIRequest("post").query(query)     }          static func get(id: UUID) -> APIRequest<Post> {         return APIRequest("post/" + id.uuidString)     } } 

Самый простой пример это

API.post.get(id:)

который в onSuccess вернет вам Post модель.

А вот более интересный пример

API.post.get(PostController.ListQuery(offset: 0, limit: 100))

который принимает на вход ListQuery модель,
которую в итоге APIRequest конвертирует в URL-path вида

post?limit=0&offset=100

и вернет в onSuccess массив [Post].

Вы конечно можете и по-старинке писать URL-path, но теперь-то вы знаете, что можно тотально Codable’зироваться.

Последний пример запроса будет DELETE

Post+Delete.swift

extension PostController {     static func delete(id: UUID) -> APIRequest<Nothing> {         return APIRequest("post/" + id.uuidString)               .method(.delete)               .desiredStatusCode(.noContent)     } } 

Здесь два интересных момента.

— возращаемый тип APIRequest, Nothing — это пустая Codable модель.
— мы явно указали, что ожидаем получить 204 NO CONTENT, и CodyFire только в этом случае вызовет onSuccess.

Как вызывать этот endpoint из вашего ViewController’a вы уже знаете.

Но тут два варианта, первый с onSuccess, а второй без. На него и посмотрим

API.post.delete(id:).execute()

То есть если вам неважно отработает ли запрос, то можете просто вызвать у него .execute() и всё, иначе он запустится после декларации onSuccess хендлера.

Доступные функции

Авторизация каждого запроса

Для подписи каждого API запроса какими-либо http-headers используется глобальный хэндлер, который вы можете задать где-нибудь в AppDelegate. Более того на выбор можно использовать классический [String: String] или Codable модель.

Пример для Authorization Bearer.

1. Codable (рекомендую)

CodyFire.shared.fillCodableHeaders = {     struct Headers: Codable {         //NOTE: если nil, то не добавиться в headers         var Authorization: String?         var anythingElse: String     }     return Headers(Authorization: nil, anythingElse: "hello") } 

2. Классика [String: String]

CodyFire.shared.fillHeaders = {     guard let apiToken = LocalAuthStorage.savedToken else { return [:] }     return ["Authorization": "Bearer \(apiToken)"] } 

Выборочное добавление некоторых http-headers в запрос

Это можно сделать при создании APIRequest, например:

APIRequest("some/endpoint").headers(["someKey": "someValue"])

Обработка неавторизованных запросов

Вы можете обрабатывать их глобально, например в AppDelegate

CodyFire.shared.unauthorizedHandler = {     //выбросить пользователя на WelcomeScreen } 

или местно в каждом запросе

API.post.create(request).onNotAuthorized {     //пользователь не авторизован } 

Если сеть не доступна

API.post.create(request). onNetworkUnavailable {     //нет связи с интернетом, либо авиарежим, либо проблемы с сетью } 

иначе в onError вы получите ошибку -1109 или в onKnownError это enum значение ._notConnectedToInternet

Кстати, onKnownError имеет приоритет перед onError. Если вы объявили и onError и onKnownError и ошибка одна из известных, то будет вызван только onKnownError.

Запуск чего-либо перед тем как запрос запустится

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

Как отключить/включить вывод логов глобально

CodyFire.shared.logLevel = .debug CodyFire.shared.logLevel = .error CodyFire.shared.logLevel = .info CodyFire.shared.logLevel = .off 

Как отключить вывод логов для одного запроса

.avoidLogError()

Обрабатывать логи по-своему

CodyFire.shared.logHandler = { level, text in     print("Ошибка в CodyFire: " + text) } 

Как задать ожидаемый http-код ответа сервера

Как я уже говорил выше, по умолчанию CodyFire ожидает получить 200 OK и если получает, начинает парсить данные и вызывает onSuccess.

Но ожидаемый код можно задать в виде удобного enum, например для 201 CREATED

.desiredStatusCode(.created) 

или даже можно задать кастомный ожидаемый код

.desiredStatusCode(.custom(777)) 

Отмена запроса

.cancel() 

и можно узнать, что запрос отменен объявив .onCancellation хендлер

.onCancellation {     //запрос был отменен } 

иначе будет вызван onError или в onKnownError

Установка таймаута для запроса

.responseTimeout(30) //ставим таймаут в 30 секунд 

на событие таймаута тоже можно повесить хендлер

. onTimeout {     //запрос завершился по таймауту } 

иначе будет вызван onError или в onKnownError.

Установка интерактивного дополнительного таймаута

Это моя любимая фишка. О ней меня однажды попросил один заказчик из США, т.к. ему не нравилось, что форма входа отрабатывает слишком быстро, по его мнению это выглядело не натурально, как-будто это фейк, а не авторизация.

Идея в том, что он хотел, чтобы проверка email/password длилась 2 секунды или более. И если она длится только 0.5 секунды, значит нужно накинуть еще 1.5 и только тогда вызвать onSuccess. А если занимает ровно 2 или 2.5 секунды, то вызывать onSuccess сразу же.

.additionalTimeout(2) //минимум 2 секунды будет выполняться запрос 

Свой Date encoder/decoder

В CodyFire есть свой DateCodingStrategy enum, в котором три значения

— secondsSince1970
— millisecondsSince1970
— formatted(_ customDateFormatter: DateFormatter)

DateCodingStrategy можно задать в трёх вариантах и отдельно для decoding и encoding
— глобально в AppDelegate

CodyFire.shared.dateEncodingStrategy = .secondsSince1970 let customDateFormatter = DateFormatter() CodyFire.shared.dateDecodingStrategy = .formatted(customDateFormatter) 

— для одного запроса

APIRequest("some/endpoint")     .dateDecodingStrategy(.millisecondsSince1970)     .dateEncodingStrategy(.secondsSince1970) 

— или даже отдельно для каждой модели, просто нужно чтобы модель соответствовала CustomDateEncodingStrategy и/или CustomDateDecodingStrategy.

struct SomePayload: JSONPayload, CustomDateEncodingStrategy, CustomDateDecodingStrategy {    var dateEncodingStrategy: DateCodingStrategy    var dateDecodingStrategy: DateCodingStrategy } 

Как добавить в проект

Библиотека доступна на GitHub под MIT лицензией.

Установка пока доступна только через CocoaPods

pod 'CodyFire'

Я очень надеюсь, что CodyFire будет полезна другим iOS-разработчикам, упростит для них разработку, и вообще сделает мир чуточку лучше, а людей добрее.

Вот и всё, спасибо, что уделили время.


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


Комментарии

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

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