Современный код для выполнения HTTP запросов в Swift 5 с помощью Combine и применение их в SwiftUI. Часть 2. Новости NewsAPI.org.

Приложение для взаимодействия с агрегатором новостей NewsAPI.org.

В предыдущей статье  на примере базы данных фильмов TMDb была представлена стратегия применения Combine для формирования HTTP запросов и использования их во View Model для управления UI, спроектированного с помощью SwiftUI. В этой статье мы в точности воспроизведем ту же самую стратегию для разработки приложения, взаимодействующего с агрегатором новостей NewsAPI.org.

Но в отличии от базы данных фильмов  TMDb, которая идеально  спроектирована и очень аккуратно поддерживается, агрегатор оперативных новостей  NewsAPI.org  наполняется разными информационными агенствами, а они, например, могут по-разному кодировать отсутствие «КАРТИНКИ» (image) для своих статей, или предоставлять её только для private доступа. Поэтому вы указываете отличную от nil ссылку на «КАРТИНКУ» (image), а она вовсе необязательно появится на экране, вы можете получить сообщение о её недоступности. Кроме того, при обращении к серверу NewsAPI.org могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или превысили допустимое количество запросов, обусловленное вашим тарифом. Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер NewsAPI.org перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.

Следовательно, надо уметь не только выбирать с помощью Combine данные из интернета, но и обрабатывать сообщения об ошибках. В этой статье мы покажем, как обработка ошибок в Combine при выборке данных позволила построить информативный UI в SwiftUI.

Рекомендуется зарегистрироваться на веб-сайте NewsAPI.org, чтобы получить ключ API, который будет обязательным для выполнения любых запросов к службе NewsAPI.org. Вы должны разместить его в файле NewsAPI.swift.

Код приложения для данной статьи находится на Github.

Воспроизведем для сервиса  NewsAPI.org стратегию применения Combine, представленную в предыдущей статье

Модель данных и API сервиса NewsAPI.org.

Сервис NewsAPI.org позволяет выбирать информацию об актуальных новостных статьях [Article] и их источниках [Source]. Наша Модель данных будет очень простой, она находится в файле Articles.swift:

import Foundation

struct NewsResponse: Codable {
    let status: String?
    let totalResults: Int?
    let articles: [Article]
}

struct Article: Codable, Identifiable {
    let id = UUID()
    let title: String
    let description: String?
    let author: String?
    let urlToImage: String?
    let publishedAt: Date?
    let source: Source
}

struct SourcesResponse: Codable {
    let status: String
    let sources: [Source]
}

struct Source: Codable,Identifiable {
    let id: String?
    let name: String?
    let description: String?
    let country: String?
    let category: String?
    let url: String?
}

Статья Article будет содержать идентификатор id, название title, описание description, автора author, URL «картинки» urlToImage, дату публикации publishedAt и источник публикации source. Над статьями [Article] находится надстройка NewsResponse, в которой нас будет интересовать только свойство articles, которое и представляет собой массив статей. Корневая структура NewsResponse и структура Article являются Codable, что позволит нам буквально двумя строками кода декодировании JSON данные в Модель. Структура Article должна быть еще и Identifiable, если мы хотим облегчить себе отображение массива статей [Article] в виде списка List в SwiftUI. Протокол Identifiable требует присутствия свойства id, которое мы обеспечим искусственным уникальным идентификатором UUID().

Источник информации Source будет содержать идентификатор id, название name, описание description, страну country, категорию источника публикации category, URL сайта url. Над источниками информации [Source] находится надстройка SourcesResponse, в которой нас будет интересовать только свойство sources, которое и представляет собой массив источников информации. Корневая структура SourcesResponse и структура Source являются Codable, что позволит нам очень просто декодировании JSON данные в Модель. Структура Source должна быть еще и Identifiable, если мы хотим облегчить себе отображение массива источников информации [Source] в виде списка List в SwiftUI. Протокол Identifiable требует присутствия свойства id, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.

Теперь рассмотрим, какой нам нужен API для сервиса NewsAPI.org, и разместим его в файле NewsAPI.swift. Центральной частью нашего API является класс NewsAPI, в котором представлены два метода выборки данных из агрегатора новостей  NewsAPI.org — статей [Article] и источников информации [Source]:

  1. fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  — выборка статей [Article] на основе параметра endpoint,
  2. fetchSources (for country: String) -> AnyPublisher<[Source], Never> — выборка источников информации[Source] для определенной страны country.

Эти методы возвращают не просто массив статей [Article] или массив источников информации [Source], а соответствующих «издателей» Publisher  нового  фреймворка Combine. Оба издателя не возвращают никакой ошибки — Never, а если ошибка выборки или кодирования все-таки имела место, то возвращается пустой массив статей [Article]() или источников информации [Source]() без каких-либо сообщений, почему эти массивы оказались пустыми. 

То, какие статьи или источники информации мы хотим выбрать сервера NewsAPI.org, будем указывать с помощью перечисления enum Endpoint:

enum Endpoint {
    case topHeadLines
    case articlesFromCategory(_ category: String)
    case articlesFromSource(_ source: String)
    case search (searchFilter: String)
    case sources (country: String)
    
    var baseURL:URL {URL(string: "https://newsapi.org/v2/")!}
    
    func path() -> String {
        switch self {
        case .topHeadLines, .articlesFromCategory:
            return "top-headlines"
        case .search,.articlesFromSource:
            return "everything"
        case .sources:
            return "sources"
        }
    }
}

Это :

  • последние новости .topHeadLines,
  • новости определенной категории (sports, healthy, science, business, technology.articlesFromCategory(_ category: String),
  • новости определенного источника информации (CNN, ABC News, Fox News и т.д.) .articlesFromSource(_ source: String),
  • любые новости .search (searchFilter: String), отвечающие определенному условию searchFilter,
  • источники информации .sources (country:String) для определённой страны country.

Для облегчения инициализации нужной нам опции добавим в перечисление Endpoint инициализатор init? для различного рода списков статей и источников информации в зависимости от индекса index и строки text, которая имеет различный смысл для разных опций перечисления :

Реализация класса NewsAPI выглядит очень похоже на класс MovieAPI из предыдущего приложения. Там присутствует та же самая Generic функция, возвращающая «издателя» AnyPublisher<T, Never>, который на основании заданного url асинхронно получает JSON информацию, декодирует и размещает её непосредственно в Codable Модели T :

Этот код используется для получения конкретного «издателя» Publisher, если исходными данными для url является, например, Endpoint  для сервиса NewsAPI.org . Он позволяет сформировать на выходе различные Модели —  массив статей[Article] или массив источников информации [Source]:

Я не буду подробно останавливаться на этапах формирования этих «издателей», так как они подробно описаны в предыдущей статье.

Полученные таким образом «издатели» сами по себе «не взлетают», они ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы будем использовать их при проектировании UI в SwiftUI и «подпишемся» на них в  ObservableObject классе, который АВТОМАТИЧЕСКИ СИНХРОНИЗИРУЕТ выбранные из интернета данные с View

«Издатели» Publisher как View Model в SwiftUI. Список статей.

Давайте сначала рассмотрим, как в SwiftUI должны функционировать полученные «издатели» на конкретном примере отображения различного рода статей:.topHeadLines— последних новостей, .articlesFromCategory(_ category: String) — новостей для определенной категории, .articlesFromSource(_ source: String) — новостей для определенного источника информации, .search (searchFilter: String) — новостей, выбранных по определенному условию.

В зависимости от того, какой Endpoint выберет пользователь, нам нужно обновлять список статей articles, выбранных с сайта NewsAPI.org. Для этого мы создадим очень простой класс ArticlesViewModel, реализующий протокол ObservableObject с тремя @Published свойствами:  

  • одно @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входом», так как его значение регулируется пользователем на View),  
  • второе @Published var searchString: String — это строка, с помощью которой осуществляется поиск произвольных статей или указывается категория интересующих нас статей  (также условно можно назвать его «входом», так как его значение регулируется пользователем на View с помощью текстового поля TextField),
  • третье @Published var articles: [Article] — список соответствующих статей (условно «выход», так как он создается путем выборки данных с сайта  NewsAPI.org, определяемых «входами»).

Как только мы поставили @Published перед свойствами indexEndpoint или searchString, мы можем начать использовать их и как простые свойства indexEndpoint и $searchString, так и «издатели» $indexEndpoint  и $searchString.

В классе ArticlesViewModel, можно не просто декларировать интересующие нас свойства, но и прописать бизнес-логику их взаимодействия. С этой целью при инициализации экземпляра класса  ArticlesViewModel в init? мы можем создать «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса ArticlesViewModel и реализовать  зависимость списка статей articles от индекса indexEndpoint и строки поиска searchString.

Для этого в Combine мы протягиваем цепочку от «издателей» $indexEndpoint  и $searchString до выходного «издателя» AnyPublisher<[Article], Never>, у которого значение — это список статей articles. Впоследствии мы «подпишемся» на него с помощью оператора assign (to: \.articles, on: self) и получим нужный нам список статей articles как «выходное» @Published свойство, определяющее UI.

Мы должны тянуть цепочку НЕ просто от свойств indexEndpoint и searchString, а именно от «издателей» $indexEndpoint и $searchString, которые будет участвовать в создании UI с помощью SwiftUI и именно их мы там будем изменять с помощью элементов  пользовательского интерфейса Picker и TextField.

Как мы будем это делать?

В нашем арсенале уже есть функция fetchArticles (from: Endpoint), которая находится в классе NewsAPI и возвращает «издателя» AnyPublisher<[Article], Never>, в зависимости от значения Endpoint, и нам остаётся только каким-то образом использовать значения «издателей» $indexEndpoint  и $searchString, чтобы превратить их в аргумент этой функции endpoint

Cначала объединим «издателей» $indexEndpoint и $searchString. Для этого в Combine существует оператор Publishers.CombineLatest:

Для создания нового «издателя» на основе данных, полученных от предыдущего «издателя» в Combine используется оператор flatMap :

Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assign (to: \.articles, on: self) и присваиваем полученное от «издателя» значение @Published массиву articles:

Мы только что создали в init( ) АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable «подписку». Основное свойство AnyCancellable «подписки» состоит в том, что как только она покидает свою область действия, занятая ею память автоматически освобождается. Поэтому, как только init( ) завершится, эта «подписка» будет удалена ARC, так и не успев присвоить полученную с задержкой по времени асинхронную информацию массиву articles.

Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ инициализатора init() переменную var cancellableSet, которая сохранит нашу AnyCancellable «подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса  ArticlesViewMode

Запоминается AnyCancellable «подписка» в переменной cancellableSet с помощью оператора .store ( in: &self.cancellableSet):

Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString, а его модифицированный вариант validString:

«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( ):

… будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModel.

Мы можем как угодно менять значение «издателей» $indexEndpoint и /или searchString, и всегда благодаря созданной «подписке» у нас будет соответствующий  значениям этих двух издателей массив статей articles без каких-либо дополнительных усилий. Такой ObservableObject класс обычно называют View Model.

Теперь, когда у нас есть View Model для наших статей, приступим к созданию пользовательского интерфейса (UI). В SwiftUI для синхронизации View c ObservableObject Моделью используется @ObservedObject переменная, ссылающаяся на экземпляр класса этой Модели. Именно эта пара — ObservableObject класс и @ObservedObject переменная, ссылающаяся на экземпляр этого класса — управляют изменением  пользовательского интерфейса (UI) в SwiftUI.

Добавим в структуру ContentViewArticles переменную var articlesViewModel, имеющую ТИП ArticlesViewModel, и заменим Text ("Hello, World!") на список статей ArticlesList, в котором разместим статьи articlesViewModel.articles, полученные  из нашей View Model.

В результате получим список статей для фиксированного и заданного по умолчанию индекса indexEndpoint = 0, то есть .topHeadLines — последние новости:

Добавим на наш экран UI элемент для управления тем, какой набор статей мы хотим показывать. Будем с помощью Picker изменять индекса $articlesViewModel.indexEndpoint. Присутствие символа $ обязательно, так как это означает изменение значения, поставляемого @Published «издателем». Сразу же срабатывает «подписка» на этого «издателя», инициированная нами в init (),  «выходной» @Published издатель» articles изменится и на экране мы увидим другой список статей:

Таким образом мы можем получать массивы статей для всех трех опций — "topHeadLines", "search" и "from category":

…  но для фиксированной и заданной по умолчанию поисковой строки searchString = "sports" (там, где она требуется):

Однако для опции "search" необходимо предоставить пользователю текстовое поле SearchView для ввода поисковой строки:

В результате пользователь сможем искать любые новости по набранной  поисковой строке:

Для опции "from category" необходимо предоставить пользователю возможность выбрать категорию и начинаем мы с категории science:

В результате пользователь сможем искать любые новости по выбранной категории новостей — science, healthbusiness, technology:

Мы видим, как очень простая ObservableObject Модель, имеющая два управляемых пользователем @Published свойства — indexEndpoint и searchString — позволяет выбрать широкий спектр информации с сайта NewsAPI.org.

Список источников информации.

Давайте рассмотрим, как в SwiftUI будет функционировать полученный в классе NewsAPI «издатель»fetchSources (for country: String) -> AnyPublisher<[Source], Never> при отображении различных источников информации.

Мы получим список источников информации для различных стран:

… и возможность поиска их по названию:

… а также детальную информацию о выбранном источнике: его имя, категорию, страну, краткое описание и ссылку на сайт: 

Если кликнуть на ссылке, то попадем на сайт этого источника информации.

Для того, чтобы все это работало нужна предельно простая ObservableObject Модель, имеющая всего два управляемых пользователем @Published свойств — searchString  и country:

И опять используем ту же схему при инициализации в init экземпляра класса  SourcesViewModel  мы  создаём «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса SourcesViewModel и реализовать  зависимость списка источников информации sources от страны country и строки поиска searchString.

С помощью Combine мы тянем цепочку от «издателей»  $searchString  и $country до выходного «издателя» AnyPublisher<[Source], Never>, значением которого является список источников информации. Мы «подписываемся» на него с помощью оператора assign (to: \.sources, on: self), получаем нужный нам список источников информации sources и запоминаем полученную AnyCancellable «подписку» в переменной cancellableSet с помощью оператора .store ( in: &self.cancellableSet).

Теперь, когда у нас есть View Model для наших источников информации, приступим к созданию UI. В SwiftUI для синхронизации View c ObservableObject Моделью используется @ObservedObject переменная, ссылающаяся на экземпляр класса этой Модели.

Добавим в структуру ContentViewSources переменную var sourcesViewModel, имеющую ТИП SourcesViewModel, уберём Text ("Hello, World!") и разместим свой View для каждого из 3-х @Published свойств sourcesViewModel :

  1.  текстовое поле SearchView для строки поиска searchString,
  2.  Picker для страны country,
  3. список SourcesList источников информации.

В результате получим нужное нам View:

На этом экране мы управляем только строкой поиска с помощью текстового поля SearchView и «страной» с помощью Picker, а остальное происходит АВТОМАТИЧЕСКИ.

Список источников информации SourcesList содержит минимальные сведения о каждом источники —  наименование source.name и краткое описание source.description:

… но позволяет получить более подробную информацию о выбранном источнике с помощью ссылки NavigationLink, в которой в качестве destination мы указываем DetailSourceView, у которого исходными данными являются сам источник информации source и нужный экземпляр класса ArticlesViewModel, позволяющий получить список его статей articles:

Посмотрите, как изящно мы получаем список статей для выбранного источника информации source в списке источников SourcesList. Нам помогает наш старый знакомый — класс ArticlesViewModel, для которого мы должны задать оба «входных» @Published свойства:

  • индекс indexEndpoint = 3, то есть опцию .articlesFromSource (_source:String), соответствующую выборке статей для фиксированного источника source,
  • строку searchString в качестве самого источника (а точнее его идентификатора) source.id :

Вообще, если вы посмотрите на всё приложение NewsApp, то нигде не увидите, чтобы мы явно запрашивали выборку статей или источников информации с сайта NewsAPI.org. Мы управляем только @Published  данными, а View Model делает свою работу: выбирает нужные нам статьи и источники информации.

Загрузка изображения UIImage для статьи Article.

Модель статьи Article содержит URL сопровождающего её изображения urlToImage:

На основании этого URL мы в дальнейшем должны получить само изображения UIImage с сайта NewsAPI.org.

Нам уже знакома эта задача. Давайте попробуем воспользоваться тем же универсальным «загрузчиком изображения» ImageLoader, который мы использовали в предыдущей статье при работа с базой данных фильмов TMDb:

Логика этого «загрузчика изображений» состоит в том, что вы загружаете изображение из отличного от nil URL при условии, что оно предварительно не загружалось, то есть image == nil. Если в процессе загрузки будет обнаружена какая-либо ошибка, то изображение будет отсутствовать, то есть image останется равным nil.

В SwiftUI мы показываем изображение с помощью ArticleImage, который использует для этого экземпляр imageLoader класса ImageLoader. Если его изображение image не равно nil, то оно показывается с помощью Image (...), а вот если оно равно nil, то в зависимости от того, чему равно его url— либо ничего не показывается — EmptyView(), либо показывается прямоугольник Rectangle с вращающемся текстом Text("Loading..."):

Эта логика прекрасно работает только для случая, когда вы точно знаете, что для url,  отличного от nil вы получите  изображение image, как это было в предыдущей статье при работа с базой данных фильмов TMDb. С агрегатором новостей NewsAPI.org  дело обстоит иначе. В статьях некоторых источников информации дается отличный от nil URL изображения, но доступ к нему закрыт. В этом случае мы получим прямоугольник Rectangle с вращающемся текстом Text("Loading..."), который никогда не будет заменен:

 

В этой ситуации, если URL изображения отличается от nil, то равенство изображения image nil может означать как то, что изображение загружается, так и то, что при загрузке произошла ошибка и мы никогда не получим  изображение image. Для того, чтобы различить эти две ситуации мы добавляем в класс ImageLoader к двум уже имеющимся @Published свойствам ещё одно: 

  •  @Published var noData = false — это булево значение, с помощью которого мы будем обозначать отсутствие данных изображения по причине возникшей ошибке при выборке.

При создании «подписки» в init ловим все ошибки Error, возникающие при загрузке изображения, и аккумулируем их присутствие в @Published свойстве self.noData = true.  Если загрузка прошла успешно, то получаем изображение image.

«Издателя» AnyPublisher<UIImage?, Error> на основе url создаем в функции fetchImageErr (for url: URL?):

Мы начинаем создание метода fetchImageErr с инициализации «издателя» Future, который можно использовать для асинхронного получения единственного значения ТИПА Result с помощью замыкания. У замыкания один параметр — Promise, который является функцией ТИПа (Result<Output, Failure>) -> Void:

Полученное Future мы превратим в AnyPublisher <UIImage?, Error> с помощью оператора «стирания ТИПА» eraseToAnyPublisher().

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

  • 0. проверяем url на nil и noData на true (то есть уже известно, что при выборке возникает ошибка и получить изображение не удастся): если это так, то возвращаем ошибку, если нет — передаем url далее по цепочке,
  • 1. создаем «издателя» dataTaskPublisher(for:), у которого на входе — url, а выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибка URLError,
  • 2. анализируем с помощью tryMap { } полученный кортежа (data: Data, response: URLResponse): если response.statusCode находится в диапазоне 200...299, то для дальнейшей обработки берем только данные data. В противном случае «выбрасываем» ошибку (неважно какую),
  • 3. в map преобразуем данные data в UIImage,
  • 4. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  • «подписываемся» на полученного «издателя» с помощью sink и его замыканий receiveCompletion и receiveValue,

5. если в замыкании receiveCompletion обнаруживаем ошибку error, то сообщаем о ней с помощью promise (.failure(error))),

6. в замыкании receiveValue сообщаем об успешном получении массива фильмов с помощью promise (.success($0))

  • 7. запоминаем полученную «подписку» в переменной var subscriptions = Set<AnyCancellable>(), чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса ImageLoader,
  • 8. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Возвращаемся к ArticleImage, в котором будем использовать новую @Published переменную noData. Если данных изображения нет, то мы ничего не будем отображать, то есть EmptyView:

Напоследок мы упакуем все наши возможности показа данных с агрегатора новостей NewsAPI.org в TabView:

Отображение ошибок при выборке и декодировании JSON данных с сервера NewsAPI.org.

При обращении к серверу NewsAPI.org могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или, имея тариф разработчика, который ничего не стоит, превысили допустимое количество запросов или еще что-то. При этом сервер NewsAPI.org снабжает вас HTTP кодом и соответствующим сообщением :

Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер NewsAPI.org перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.

До сих пор при выборке статей [Article] и источников информации [Source] с сервера NewsAPI.org мы игнорировали все ошибки, и в случае их появления возвращали в качестве результата пустые массивы [Article]() и   [Source]().

Приступая к обработке ошибок, давайте на основе уже существующего  метода fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> выборки статей создадим в классе NewsAPI другой метод fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>, который будет возвращать не только массив статей [Article], но и возможную ошибку NewsError:

func fetchArticlesErr(from endpoint: Endpoint) ->
                            AnyPublisher<[Article], NewsError> {

. . . . . . . .
}

Этот метод, также, как и метод fetchArticles, на входе принимает endpoint и возвращает «издателя» со значением в виде массива статей [Article], но вместо отсутствия ошибки Never, у нас может присутствовать ошибка, определяемая перечислением NewsError: (точно таким же как в предыдущей статье, только с другим названием)

Начнем создание нового метода с инициализации «издателя» Future, который можно использовать для асинхронного получения единственного значения ТИПА Result с помощью замыкания. У замыкания один параметр — Promise, который является функцией ТИПа (Result<Output, Failure>) -> Void:

Полученное Future мы превратим в нужного нам «издателя» AnyPublisher <[Article], NewsError> с помощью оператора «стирания ТИПА» eraseToAnyPublisher().

Далее мы повторим все шаги, которые мы делали в методе fetchArticles, но при этом будем учитывать все возможные ошибки:

  • 0. на основе endpoint формируем URL endpoint.absoluteURL для запроса нужной коллекции статей, проверяем url на nil: если nil, то возвращаем ошибку .urlError, если нет — передаем url далее по цепочке,
  • 1. создаем «издателя» dataTaskPublisher(for:), у которого на входе — url, а выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибка URLError,
  • 2. анализируем с помощью tryMap { } полученный кортежа (data: Data, response: URLResponse): если response.statusCode находится в диапазоне 200...299, то для дальнейшей обработки берем только данные data. В противном случае «выбрасываем» ошибку .responseError, снабжая её данные data, преобразованными в строку String, содержащую истинную ошибку сервера,
  • 3. декодируем JSON данные непосредственно в Модель, которая представлена структурой NewsResponse,
  • 4. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  • «подписываемся» на полученного «издателя» с помощью sink и его замыканий receiveCompletion и receiveValue,

5. если в замыкании receiveCompletion обнаруживаем ошибку error, то сообщаем о ней с помощью promise (.failure(...))),

6. в замыкании receiveValue сообщаем об успешном получении массива статей с помощью promise (.success($0.articles))

  • 7. запоминаем полученную «подписку» в переменной var subscriptions = Set<AnyCancellable>(), чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса NewsAPI,
  • 8. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Следует отметить, что «издатель» dataTaskPublisher(for:) отличается от своего прототипа dataTask тем, что в случае ошибки сервера, когда response.statusCode НЕ находится в диапазоне 200...299, он всё равно поставляет успешное значения в виде кортежа (data: Data, response: URLResponse), а не ошибку в виде (Error, URLResponse?). В этом случае реальная информация об ошибке сервера содержится в data. «Издатель» dataTaskPublisher(for:) поставляет ошибку URLError, если возникает ошибка на клиентской стороне (невозможность связаться с сервером, запрет системы безопасности ATS и т.д.) .

Если мы хотим отображать ошибки в SwiftUI, то нужна соответствующая View Model, которую мы назовем ArticlesViewModelErr:

В классе ArticlesViewModelErr, реализующем протокол ObservableObject, у нас на этот раз ЧЕТЫРЕ @Published свойства:

  1. @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входным», так как регулируется пользователем на View), 
  2. @Published var searchString: String — это поисковая строка, смысл которой зависит от Endpoint: это может быть «категория» новостей. источник информации или действительно поисковая строка (условно это свойство также можно назвать «входным», так как оно будет регулироваться пользователем на View), 
  3.  @Published var articles: [Article] — список соответствующих статей (условно «выходное», так как получается путем выборки данных с сайта NewsAPI.org )
  4.  свойство @Published var articlesError: NewsError? — это ошибка, которая может возникнуть на любом этапе  выборки данных с сайта NewsAPI.org.

При инициализации экземпляра класса ArticlesViewModelErr мы опять должны протянуть цепочку от входного «издателей» $indexEndpoint и $searchString до выходного «издателя» AnyPublisher<[Article],NewsError>, на которого мы «подписываемся» с помощью «подписчика» sink и получаем либо массив статей articles, либо ошибку articlesError.

В нашем NewsAPI мы уже сконструировали  функцию fetchArticlesErr (from endpoint: Endpoint), которая возвращает «издателя» AnyPublisher<[Article], NewsError>, в зависимости от значения endpoint, и нам нужно только каким-то образом использовать значения «издателей» $indexEndpoint  и $searchString, чтобы превратить их в аргумент этой функции endpoint

Для начала объединим «издателей» $indexEndpoint и $searchString. Для этого в Combine существует оператор Publishers.CombineLatest:

Затем мы должны установить для полученного «издателя» ТИП ошибки равный требуемому NewsError:

Далее мы хотим воспользоваться функцией fetchArticlesErr (from endpoint: Endpoint) из нашего NewsAPI. Как обычно, мы будем это делать с помощью оператора flatMap, который создает нового «издателя» на основе данных, полученных от предыдущего «издателя»:

Затем мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика» sink и используем его замыкания receiveCompletion и receiveValue для получения от «издателя» либо значения массива статей articles, либо ошибки articlesError:

Естественно, нам необходимо запомнить полученного в результате «подписчика» в некоторой внешней по отношению к init() переменной cancellableSet. Иначе мы не сможем асинхронно получить значение articles или ошибку articlesError после завершения init():

Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString, а его модифицированный вариант validString:

«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init(), будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModelErr:

Приступим к коррекции нашего UI с целью возможных ошибок выборки данных.

В SwiftUI в уже имеющейся структуре ContentVieArticles  используем другую, только что полученную View Model, лишь добавив в названии буквы «Err». Это экземпляр класса  ArticlesViewModelErr, который «улавливает» ошибку выборки и/или декодирования данных о статьях с сервера NewsAPI.org:

И ещё добавляем показ экстренного сообщения Alert для случая появления ошибки.

Например, если неверный API ключ:

struct APIConstants {
    // News  API key url: https://newsapi.org
    static let apiKey: String = "API_KEY" 
    
   .  .  .  .  .  .  .  .  .  .  .  .  .
}

… то мы получим такое сообщение:

Если лимит запросов исчерпан,  то  мы получим такое сообщение:

Возвращаясь к методу  выборки статей [Article]  с возможной ошибкой NewsError,  мы можем упростить его код,  если будем использовать Generic «издателя» AnyPublisher<T,NewsError>, который на основании заданного url асинхронно получает JSON информацию, размещает её непосредственно в Codable Модели T и сообщает об ошибке NewsError:

Как мы знаем, этот код очень легко использовать для получения конкретного «издателя», если исходными данными для url является Endpoint для агрегатора новостей  NewsAPI.org или страна country источника информации, а на выходе требуются различные Модели — например, список статей или источников информации:

Заключение.

Для создания приложения, взаимодействующего с агрегатора новостей NewsAPI.org, мы воспользовались в точности той же самой технологией, которую мы использовали в предыдущей статье для работы с базой данных фильмов TMDb.

Также как и в предыдущей статье мы опирались на код простого Generic «издателя» AnyPublisher<T, Never>, который асинхронно получает JSON информацию и размещает её непосредственно в Codable Модели T на основании заданного url:

Мы использовали его для получения «издателя» AnyPublisher<[Article], Never>, публикующего набор статей, и «издателя»  AnyPublisher<[Source], Never>, публикующего список источников информации с агрегатора новостей NewsAPI.org:

Полученных «издателей», как оказалось, очень просто «заставить работать» в ObservableObject классах, которые с помощью своих @Published свойств управляют  UI, спроектированным с помощью SwiftUI. Эти классы иногда называют View Model.
Вот пример View Model для отображения различных списков статей articles в зависимости от индекса indexEndpoint нужной нам коллекции статей и поисковой строки searchString:
 

 

Эта простейшая View Model позволяет нам получить различные наборы статей и источников информации с сервера NewsAPI.org :

  • последние новости,
  • новости, выбранных для определенной категории (sports, healthy, science, business, technology) ,
  • новости, выбранных для определенного источника информации (CNN, ABC News, Fox News и т.д.) ,
  • любых новостей, выбранных по определенному условию searchFilter, содержащемуся в заголовке статьи,
  • источники информации.

Используя аналогичный подход, создаём «издателя» AnyPublisher<UIImage?, Error> для выборки изображений, который на основе URL асинхронно выбирает данные и пытается получить изображение UIImage. Если при выборке данных изображения произошла ошибка, то она фиксируется нашим «издателем» и мы не будем ожидать появления такого изображения на экране, даже если его URL не равен nil. Это особенно актуально для агрегатора оперативных новостей  NewsAPI.org , который наполняется разными информационными агенствами, а они, например, могут по-разному кодировать отсутствие изображения  для своих статей, или предоставлять её только для private доступа.  Загрузчики изображений кэшируются в памяти для того, чтобы избежать повторной асинхронной выборки данных.

В статье показано, как учитывать ошибки, возникающие при обращении к серверу NewsAPI.org, когда, возможно, неправильно задан ключ API-key или превышено допустимое количество запросов, определяемое тарифом подписки. 

Код приложения для данной статьи находится на Github.

Ссылки:

Modern Networking in Swift 5 with URLSession, Combine and Codable.

URLSession.DataTaskPublisher’s failure type

Combine: Asynchronous Programming with Swift

«SwiftUI & Combine: Лучше вместе»

Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722

(конспект сессии 722 «Введение в Combine» на русском языке)

Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721

(конспект сессии 721 «Практическое применение Combine» на русском языке)