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

Начиная со 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)
                                  .addCustomError(.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").onError { 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)
}

onError и 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)
                .addError(.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)")
}.onError { 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, в нем указан generic тип 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 вы получите ошибку ._notConnectedToInternet

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


Вы можете задать .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

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


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

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

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

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

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


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

Идея в том, что он хотел, чтобы проверка 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-разработчикам, упростит для них разработку, и вообще сделает мир чуточку лучше, а людей добрее.

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

UPD: Появилась поддержка ReactiveCocoa и RxSwift
pod 'ReactiveCodyFire' #для ReactiveCocoa
pod 'RxCodyFire' #для RxSwift
#в данном случае не нужно прописывать 'CodyFire', он уже в зависимостях

APIRequest для ReactiveCoca будет иметь .signalProducer, а для RxSwift .observable

UPD2: появилась возможность запускать несколько запросов
Если вам важно получить результат каждого запроса, используйте .and()
Максимально в таком режиме можно запустить до 10 запросов, они будут выполняться строго один за другим.
API.employee.all()
    .and(API.office.all())
    .and(API.car.all())
    .and(API.event.all())
    .and(API.post.all())
    .onError { error in
        print(error.description)
    }.onSuccess { employees, offices, cars, events, posts in
   // все результаты получены !!! 
}

onRequestStarted, onNetworkUnavailable, onCancellation, onNotAuthorized, onTimeout — также доступны.
onProgress — пока в разработке

Если вам не важны результаты запросов, можно использовать .flatten()
[API.employee.all(), API.office.all(), API.car.all()].flatten().onError {
    print(error.description)
}.onSuccess {
    print("flatten finished!")
}
Чтобы запустить их одновременно просто добавьте .concurrent(by: 3) это позволит трём запросам выполняться одновременно, число можно указать любое.
Чтобы пропускать ошибки провалившихся запросов добавьте .avoidCancelOnError()
Чтобы получить прогресс выполнения добавьте .onProgress

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


  1. GDXRepo
    27.10.2018 15:58
    +1

    Неплохой подход. Наверное, каждый что-то подобное пилит в каждом своем проекте)) У меня тоже что-то такое встроено самописное поверх Аламофаера. Но возьму на заметку, спасибо. Если будете поддерживать либу, то отлично.

    P.S. Где-то в разметке статьи забыли <i* тег закрыть, или закрыли не там, где стоило) Пофиксите пж, а то весь подвал в курсиве)


    1. iFamily Автор
      27.10.2018 16:57

      Спасибо большое, текст исправил, там был код который парсер срезал и получился глюк)
      Либа будет жить долго ^_^


  1. Gargo
    27.10.2018 16:58

    поправьте меня если ошибаюсь, но в случае какой-то ошибки с запросом вы увидите только что «что-то не работает». Либо вам придется писать полноценный парсинг значений, с которым Codable отличается от существующих решений только тем, что поставляется с ios sdk.
    Про использование структур вместо классов промолчу — на клиенте ужасно неудобная штука, но может на сервере все по-другому


    1. iFamily Автор
      27.10.2018 17:12

      в случае какой-то ошибки с запросом вы увидите только что «что-то не работает»
      Все что может пойти не так на мой взгляд охвачено, это:
      — либо нет связи, таймаут
      — либо ответ от сервера с неожиданной ошибкой, например 401 вместо 200
      — либо ошибка декодирования о которой вы получите полную информацию в дебагере, иначе будет брошен код -2 или enum `_undecodable`

      Можно конечно добавить еще и парсинг объекта ошибки сервера, например если сервер всегда бросает на ошибку json вида {error: 401, reason: «Not authorized because token is empty»} или просто строку тогда это можно будет спарсить. Можно было бы сделать, но тогда надо будет больше дженериков(в принципе уже даже придумал как сделать красиво). А так все ошибки видно в дебагере более чем полностью с указанием и всех данных о запросе и об ответе.

      Про использование структур вместо классов промолчу
      А вот чем вам структуры не угодили я не понимаю. Их правильно использовать для Codable, потому что если вы будете использовать классы, то наверняка из-за наследования, а Codable не умеет в наследование и будут ошибки, т.к. часть переменных из суперклассов потеряются, если вы не пропишете кастомный encoder/decoder вручную. Попробуйте в playground сделать JSONEncode класса с наследованием, он выведет вам только значения из суперкласса. Или попробуйте используйте Mirror для чтения переменных класса, он выведет вам только все из первого класса, а из суперкласса нет. Чтобы никто с этим не сталкивался дан пример со структурами.


      1. Gargo
        27.10.2018 18:09

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


        1. iFamily Автор
          27.10.2018 18:21

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

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

          В случае со структурами вы обязаны написать километровый init с кучей входных параметров, чтобы инициализировать все поля?
          Актуально только для структур для запросов, для ответов никакие инициализаторы не нужны. Кстати с классом вы тоже будете писать километровый init чтобы инициализиировать все поля если вы не расставили `!` у каждого поля конечно же (а force unwrapping это кстати зло)

          получите рефакторинг всех конструкторов
          Звучит совсем не страшно, так как делать это придется очень редко и это не так уж и сложно.


        1. house2008
          28.10.2018 14:07

          Есть плагины для Xcode чтобы генерировать иниты, например github.com/rjoudrey/swift-init-generator. Я так понимаю, что в классах у вас все проперти являются var, чтобы избавиться от конструктора, тогда мы теряем иммутабельность объекта. К тому же структуры более эффективны касательно работы с памятью, правда если все проперти так же являются value типами.


  1. deej
    27.10.2018 19:06

    Планируете поддержку ReactiveCocoa и RxSwift?


    1. iFamily Автор
      27.10.2018 19:20

      Уже получал этот вопрос, планирую.
      Или может быть у вас есть желание прислать pull request? :)


      1. deej
        27.10.2018 19:23

        Желание-то есть. Дружко.jpg


        1. iFamily Автор
          27.10.2018 22:12

          Уже готово, обновил пост :)


          1. deej
            27.10.2018 22:34

            Спасибо! Обязательно попробую на следующем проекте


  1. GDXRepo
    28.10.2018 20:31

    Перечитал статью, подумал вот о чем. Мне сильно не хватает в оберточных либах возможности делать dependent-запросы. То есть, зачастую нужно, чтобы отработал вот этот запрос, потом вот этот, и еще вот этот, и только тогда считать группу запросов выполненной. Например, если каждый из них отработал без ошибок, или только парочка из трех запрошенных, допустим. Сейчас решаю это тупой вложенностью (один запрос — onSuccess — второй запрос — onSuccess, и т.д.), что не совсем верно, так как запросы не одновременно идут, но мне хватает. Есть ли планы ввести что-то подобное в либу?

    Еще похожая тема — chained-запросы, при котором один запрос идет строго после другого, цепочкой, а результатом является опять результат выполнения группы цепных запросов.

    И еще одно — отмена запросов. Юзер перешел на другой экран, ткнул отмену или что-то такое — надо иметь возможность обрубить левые запросы, которые более не нужны.

    С этими плюшками будет прям огонь.


    1. Bogdan9122
      29.10.2018 17:17

      все что вы описали можно сделать с помощью реактивщины.


      1. GDXRepo
        29.10.2018 17:21

        Можно. А все, что сделал автор в либе, можно сделать и без этой либы. Но я говорю конкретно о ней и ее фишках, а не о других способах выполнить задачу. Другие способы есть всегда.


    1. iFamily Автор
      30.10.2018 03:02
      +1

      Спасибо большое, я постарался сделать описанный вами функционал.
      Надеюсь, что все понял правильно, посмотрите пожалуйста UPD2 в статье :)


      1. GDXRepo
        30.10.2018 04:29

        Очень неплохо, спасибо, стало значительно круче.