Pull to refresh

Qt / QML REST Client

Reading time 29 min
Views 15K
Увидел сегодня в ленте статью и вспомнил, что хотел ведь про свой проект пару строк на Хабр написать.

В общем, некоторое время я работал техлидом с программистами iOS/Android, которые много использовали в своем коде API на Django/Yii2/проприетарщине. И посмотрев со стороны на инструменты, имеющиеся у них для работы с REST API, я решил нечто подобное добавить и в Qt, т.к. нормальных средств по работе с REST с использованием Qt моделей не существовало.

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

image

Итак, вот что мы обсудим:

  1. Идея и фичи
  2. Архитектура
  3. Пример использования
  4. Исходный код и приложение-пример

Идея и фичи


По своей сути, любое нормально спроектированное REST API сводится к приему HTTP запросов и отдаче на клиент списочных/одиночных объектов данных в JSON/XML.

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

Исходя из этих посылов, я решил, что библиотека должна отвечать следующим требованиям:

  • Доступность из C++ и QML;
  • Основываться на QAbstractListModel с поддержкой (переопределением) методов fetchMore() и canFetchMore() для автоматической подгрузки новых страниц в элементах списков (ListView, GridView, etc);
  • Прием и парсинг данных в форматах JSON/XML;
  • Параметризация постраничного разбиения (pagination): по странциам (per page), по лимиту/сдвигу (limit/offset), по курсору (cursor);
  • Параметризация сортировки;
  • Параметризация фильтрации;
  • Параметризация списка возвращаемых полей для списочных данных;
  • Поддержка аутентификации;
  • Использование API без моделей;
  • Поддержка ленивой загрузки данных (lazy load) для навигации типа «Список -> Детальное описание элемента»;
  • Разделение моделей и конкретных API методов, а также простота реализации API в конечном приложении;
  • Требовать наличие в API ключевого поля для каждого элемента (для операций над данными);
  • Поддержка множества внешних API в рамках одного приложения, таким образом, чтобы различные модели и API классы были максимально независимы друг от друга;
  • Наличие функциональных базовых моделей и возможность создания собственных.

При выработке требований я ориентировался на такие серверные средства создания API, как Yii2-REST и Django REST framework, т.к. это по моему мнению наиболее функциональные свободные решения для создания REST-сервисов, к тому же они происходят из совершенно разных миров и при анализе их документации, а также написании тестовых проектов, я получил данные различных подходах к организации REST на сервере.

Архитектура


Итак, как было сказано выше, все у нас крутится вокруг QAbstractListModel, ведь это нативный способ доступа к данным из Qt C++/QML. Давайте перейдем к конкретике.

Согласно схеме выше, мы имеем два базовых класса: APIBase (наследник QObject) и BaseRestListModel (наследник QAbstractListModel).

  • APIBase — это базовый класс для ваших итоговых API, которые работают непосредственно с сервером. Вся работа с сетью должна быть инкапсулирована в его наследниках;
  • BaseRestListModel — этот класс является абстрактным, внутренним классом, который в своем приложении, программист использовать по идее никогда не будет. Класс описывает все необходимые свойства и методы для работы с API-классом. В свою очередь данные класс наследуют два класса, с которыми программисту уже придется столкнуться — AbstractJsonRestListModel и AbstractXmlRestListModel. Как видно из назвния, эти классы представляют собой парсеры данных в форматах json/xml. Если вам понадобится реализовать парсер для нового формата данных (csv? =) ), то просто сделайте по все аналогии с этими двумя и выше по иерархии все заведется автоматом.

Свойства класса APIBase:

  • accept — запрашиваемый формат данных, задается классами AbstractJsonRestListModel и AbstractXmlRestListModel, соответственно application/json и application/xml;
  • acceptHeader — имя заголовка для свойства accept. По умолчанию, что логично заголовок называется «Accept». Смысл этого и предыдущего свойства в том, что и Yii2 и Django и наверняка другие сервисы умеют сериализовать данные из БД в json/xml на лету;
  • baseUrl — просто наш базовый url, к которому в конец будут добавляться наименования вызываемых API методов и параметры;
  • authToken — токен авторизации, с полный текстом (ну там, «Bearer 8aef452ee3b32466209535b96d456b06»);
  • authTokenHeader — наименованиие заголовка с токеном, по умолчанию — «Authorization»;

Этого набора свойств в принципе хватает для работы с любым сервисом. Каждое свойство само собой доступно в классе-наследнике и в QML (т.к. все они — Q_PROPERTY).

Для написания своего класса для работы с API, нужно унаследоваться от APIBase и как минимум реализовать метод handleRequest, а также все необходимые методы по получению данных с сервера, используя вышеуказанные свойства, параметры из модели (будет ниже) и protected методы get, post, put, deleteResource, head, options, patch (все соответствуют этим же методам в HTTP протоколе).

Вот и все, внутри ваших методов получения данных должен быть код по разбору переданных из модели (читай — из приложения) параметров, а далее — дело техники, останется лишь сформировать и отправить корректный запрос на сервер используя QUrl/QUrlQuery. В результате получается указатель на QNetworkReply, который возвращается в базовый класс и уже там происходит подписка на его сигналы завершения.

Рассмотрим разные сценарии использования своего API класса, совместно с моделями.

1. handleRequest и готовые ReadOnly-модели


Данный сценарий используется, когда вам не нужны собственные модели данных и вы хотели бы обойтись готовыми. В библиотеке есть две такие модели — JsonRestListModel и XmlRestListModel.

Обе указанных модели являются ReadOnly и сразу готовы к использованию из C++/QML

Для работы с ReadOnly-моделями, необходимо реализовать метод handleRequest в API-классе, вот его интерфейс:

virtual QNetworkReply *handleRequest(QString path, 
					QStringList sort, 
					Pagination *pagination,
                                        QVariantMap filters = QVariantMap(),
                                        QStringList fields = QStringList(), 
					QString id = 0)

где path — API-метод, sort — параметры сортировки, pagination — объект параметров пейджинга, filters — параметры фильтрации, fields — список возвращаемых полей, id — уникальный идентификатор записи.

Каждая из ReadOnly-моделей реализует доступ к API в следующем виде:

QNetworkReply *JsonRestListModel::fetchMoreImpl(const QModelIndex &parent)
{
    Q_UNUSED(parent)
    return apiInstance()->handleRequest(requests()->get(), sort(), pagination(), filters(), fields());
}

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

...
MyApi {
    id: myApi
}

JsonRestListModel {
    id: jsonSampleModel
    api: myApi //ссылка на API объект

    idField: 'id' //поле - уникальный идентификатор записи

    //инициализация списка доступных методов API для handleRequest
    //в ReadOnly модели поддерживаются только readOnly методы get (список записей) и getDetails (расширенная информация по записи)
    //для расширения кол-ва методов см. класс Requests
    requests {
        get: "/v1/coupon"
        getDetails: "/v1/coupon/{id}"
    }

    //Задание фильтров для списка
    filters: {'isArchive': '0'}

    //Задание списка нужных полей
    fields: ['id','title']

    //Задание сортировки
    sort: ['-id']

    //Задание метода пагинации
    pagination {
        policy: Pagination.PageNumber //тип - по номеру страницы
        perPage: 20 //кол-во записей на одну страницу
    }

    //Даем команду модели на загрузку данных сразу после инициализации
    Component.onCompleted: { reload(); }
}
...

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

2. Написание собственных моделей


Если ReadOnly модели нас не устраивают, то можно унаследоваться от AbstractJsonRestListModel и AbstractXmlRestListModel и создать собственную модель со всеми необходимыми методами по манипуляции данными. Подробнее поговорим об этом типе моделей в примере использования.

Добавлю, что для манипуляции данными, можно использовать два варианта:
  • Создать метод для создания/обновления в API и использовать его напрямую;
  • В API создать лишь метод для отправки запроса на изменение на сервер, а в модели реализовать подготовку данных.

Здесь я решил не вводить никаких ограничений, но второй вариант будет вернее. поясню почему. Каждый элемент модели является экземпляром класса RestItem и в самой модели существуют такие методы-хелперы, как:
  • RestItem BaseRestListModel::createItem(QVariantMap value) — создать новый объект модели из полученного и сложенного в QVariantMap JSON/XML ответа;
  • RestItem BaseRestListModel::findItemById(QString id) — найти элемент модели по его ID. Вызвав данный метод в методе обновления данных и получив новый объект, в него можно внести изменения, положить измененный объект в модель (как в методе updateItem) и наконец использовать обновленный объект для отправки на сервер.

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

Сам же класс RestItem нужен для методов моделей data() и roleNames(), которые являются стандартными методами Qt, для представления доступных ключей/значений в QML.

3. Использование API-класса напрямую


Наконец, мы можем обойтись и вовсе без моделей, описав всю логику работы в APi классе, для этого достаточно переопределить метод replyFinished и делать запросы через API напрямую.

Хм… Увлекся описанием сценариев конечно… Идем далее, к моделям. Как я и сказал, базовый класс всех моделей — это BaseRestListModel. Собственно, этот базовый класс делает практически всю работу.

Итак, список свойств класса:

  • APIBase *api — указатель на API объект модели. Указатель, потому что по хорошему, с одним сервисом внутри приложения должен работать один объект API. Указатель может быть задан как в QML (выше), так и быть передан из C++;
  • QStringList sort — параметры сортировки. Это QStringList, где одна строка = 1 поле сортировки, как они обрабатываются на сервере — нам пофиг. Пример: ['-id', 'name'] — предполагается, что сервер в данном случае отсортирует данные по убыванию поля id и по имени;
  • Pagination *pagination — указатель на объект пагинации. Pagination — это отдельный класс, который задает и хранит состояние пагинации для данной модели. о нем чуть ниже;
  • QVariantMap filters — массив с фильтрами, внутри — QVariantMap, из QML задается так: "{'isArchive': '0'}", что означает «поле isArchive должно равняться нулю». В значение поля можно передавать все что угодно, включая ">, <, >=, <=" — тут главное, чтобы ваш сервис смог понять такую команду;
  • QStringList fields — т.к. REST сервисы могут возвращать не все поля, а для списков на мобилках актуально получить не 20 полей, где есть даже тип TEXT и BLOB, а только 2-3 используемых в списке поля, то и здесь было добавлено такое поле, заполняя которое, можно управлять получением полей;
  • QString idField — наименование ключевого поля, по нему как правило проводятся операции изменения данных и получения расширенных (Details) сведений по каждой записи;
  • QString fetchDetailLastId — ключ последней записи, для которой была запрошена расширенная атрибутика;
  • DetailsModel *detailsModel — спец. модель, которая хранит расширенную атрибутику для одной (последней запрошенной) записи. Эту модель можно использовать на страницах детальной информации о записи. Ну например — листаем мы ленту на YouTube, ВК, Пикабу… Кликаем на пост — загружаются там комменты, полный текст, инфо о видео и прочая бабуйня;
  • LoadingStatus loadingStatus — через это свойство модель сообщает о своем текущем состоянии, на основании него можно подстраивать состояние приложения и анимации внутри него. Может принимать значения: Idle, IdleDetails, RequestToReload, FullReloadProcessing, LoadMoreProcessing, LoadDetailsProcessing, Error;
  • loadingErrorString, loadingErrorCode — хранятся сообщения об ошибках последнего запроса;
  • count — текущее количество записей в модели.

Здесь также, все свойства являются Q_PROPERTY.

Помимо свойств, каждая унаследованная от данного класса модель имеет в распоряжении следующие методы:

  • void reload() — полностью перезагрузить данные модели;
  • void fetchDetail(QString id) — метод для получения детальной инфы по записи, заполняет данными модель DetailsModel, доступную через свойство *detailsModel;
  • void requestToReload() — лишь изменяет состояние подели на RequestToReload, без выполнения фактического запроса. нужно, если мы хотим выполнить доп. действия между изменением состояния GUI и реальным запросом. Использует переопределенный в пользовательской модели метод fetchDetailImpl;
  • void forceIdle() — возвращает модель в Idle состояние из любого другого, обрывает процесс загрузки;
  • bool canFetchMore() — на основании объекта пагинации и текущего состояния возвращает модели, возврашает инфу о том, есть ли еще данные для получения. Служебный метод, автоматически используется в ListView, GridView, PathView;
  • void fetchMore() — собственно метод, для поулчения данных. Учитывает состояние пагинации и вызывает метод получения данных из пользовательской модели, переопределяемый под именем fetchMoreImpl;
  • int rowCount() — кол-во записей в модели на текущий момент;

Отнаследовавшись от AbstractJsonRestListModel или AbstractXmlRestListModel, для создания рабочей модели, необходимо также реализовать ряд методов:

  • virtual QNetworkReply *fetchMoreImpl(const QModelIndex &parent) — метод, реализующий реальное получение новых данных из API;
  • virtual QNetworkReply *fetchDetailImpl(QString id) — метод, реализующий получение детальных сведений о записи;
  • virtual QVariantMap preProcessItem(QVariantMap item) — этот метод позволяет провести препроцессинг каждой записи между получением из JSON/XML и перед добавлением в модель. Вообще, задача подготовки данных — это задача бекенд-девелопера, но если вам к примеру требуется вывести поле даты в 5 разных форматах, то лучше сделать препроцесс на клиенте, чем гонять +5 полей по сети;
  • virtual QVariantList getVariantList(QByteArray bytes) — метод парсинга JSON/XML, он уже переопределен в AbstractJsonRestListModel и AbstractXmlRestListModel, вам нет необходимости о нем вспоминать в своем приложении;
  • virtual QVariantMap getVariantMap(QByteArray bytes) — аналогично предыдущему, но парсит не список объектов, а один объект.

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

Как я говорил ваше, с моделями связаны два специфических класса — Pagination и DetailsModel.
С DetailsModel в принципе все просто. При клике на элемнт списка в приложении, запрашиваем данные, заполняем ими эту модель, отдаем в приложение указатель. В приложении правда придется малость извратиться и создать не интерактивный ListView с одним элементом, передав ему нужны делегат и указатель на детальную модель — таким образом и получим «страницу детальной информации».

С Pagination тоже не должно возникнуть проблем. Это класс определяем лишь параметры пагинации и хранит текущее состояние для модели. задается все также через набор свойств:

  • PaginationPolicy policy — принимает значения None, PageNumber, LimitOffset, Cursor, Infinity. Думаю объяснять смысл полей без надобности;
  • Для policy PageNumber задаются свойства perPage, currentPage/currentPageHeader (readOnly — ткущая страница из хидера с сервера), pageCount/pageCountHeader (также читаем из соответствующего заголовка с сервера). То есть, задаем perPage, поулчает от сервера кол-во страниц и текущую страницу, юзаем в canFetchMore;
  • Для policy LimitOffset и Cursor присутствуют ReadOnly поля totalCount/totalCountHeader. То есть, поулчаем с сервера инфу по общему кол-ву записей;
  • Для LimitOffset задаем limit и offset;
  • Для Cursor задаем cursorQueryParam и cursorValue.

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

Ах да, еще существует класс Requests, который используется в ReadOnly моделях и в QML. Гляньте на исходники, там все просто)

Вот примерно такая архитектура у меня и получилась. Разумеется, я писал библиотеку и старался сразу же применить ее на практике, поэтому сделал приложение-демку, которое можно скомпилить и посмотреть. Вот на его примере сейчас и разберем по шагам использование библиотеки.

Пример использования


Есть у меня один маленький проект на Yii2, который расположен по адресу… не скажу какому, а то знаю я Хабр))

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

Ниже приведены используемые API методы и данные, которые они возвращают.

/v1/categories
[{
"id": 1,
"sourceServiceId": 2,
"categoryName": "Акции",
"categoryCode": "aktsii",
"categoryIdentifier": "0",
"parentCategoryIdentifier": "0",
"categoryAdditionalInfo": "0",
"isActive": 1
},
  {
"id": 2,
"sourceServiceId": 2,
"categoryName": "Купоны",
"categoryCode": "kupony",
"categoryIdentifier": "28",
"parentCategoryIdentifier": "28",
"categoryAdditionalInfo": "https://blizzard.kz/kuponator/categ/28",
"isActive": 1
}
, ...]


/v1/coupon
[{
"id": 1,
"sourceServiceId": 1,
"cityId": 1,
"createTimestamp": "2015-03-12 14:01:57",
"lastUpdateDateTime": "2016-10-20 03:54:47",
"recordHash": "e7b01c1a69bc66e1f1a62d8fcb0825de",
"title": "Home Club",
"shortDescription": "Аренда коттеджа с двумя спальнями, горки, сауна, дартс и многое другое",
"longDescription": " Предпраздничные деньки – отличный повод для выезда за пределы города. Отдохните от машин, пробок и смога – приезжайте в семейно-оздоровительный комплекс Home Club. Именно здесь Вы можете арендовать комфортабельный коттедж со скидкой до 50%! Также к Вашим услугам футбольное поле, дартс, сауна и многое другое. Отдыхайте с душой! ",
"conditions": " <p class="e-condition__text">Условия:</p> <ul class="b-conditions-list"> <li class="e-condition">Сертификат предоставляет возможность провести время в природно-развлекательном парке Home Club.</li> <li class="e-condition"> <strong>Бонус</strong>: скидка 50% на пейнтбол - 1 500 тг. вместо 3 000 тг.</li> <li class="e-condition"> После приобретения сертификата дополнительно оплачиваются только бонусные услуги (по желанию).</li> <li class="e-condition"> Завтраки не входят в стоимость сертификата.</li> <li class="e-condition"> Коттедж рассчитан до 10 человек.</li> <li class="e-condition"> <strong>VIP-коттеджи действительны только в будние дни. А также VIP-коттеджи не действительны в праздничные дни.</strong> </li> <li class="e-condition"> Перед приобретением сертификата необходимо уточнять наличие свободных коттеджей.</li> <li class="e-condition"> <strong>Необходима предварительная запись по телефонам:</strong><br> +7 (727) 308-23-63,<br> +7 (747) 841-42-51,<br> +7 (701) 985-90-72.</li> <li class="e-condition"> <strong>Предварительная бронь не переноситься.</strong> </li> <li class="e-condition"> Бронь будет держаться в течение 2 часов, после приобретения сертификат активируется (бронь аннулируется).</li> <li class="e-condition"> Если Вы забронировали коттедж и не воспользовались услугой, стоимость купона не выплачивается и купон «сгорает».</li> <li class="e-condition"> Сертификат необходимо активировать в комплексе Home Club по адресу: Алматинская область, по верхней Каскеленской трассе, поселок Жандосов, Home Club.</li> <li class="e-condition"> Вы можете приобрести неограниченное количество сертификатов по данной акции, как для себя, так и в подарок.</li> <li class="e-condition"> <strong>Обязательно предъявляйте распечатанный сертификат при заезде.</strong> </li> <li class="e-condition"> Сертификат действителен до 12 апреля 2015 г. (включительно).</li> <li class="e-condition"> <span hashstring="deal_refunds_policy" hashtype="content">&nbsp</span> </li> <li class="e-condition"> <span hashstring="deal_standard_conditions" hashtype="content">&nbsp</span> </li> </ul> <p class="e-offer__features">Адрес</p> <ul class="b-offer__features-list"> <li class="e-offer__feature "> Алматинская область, по верхней Каскеленской трассе, поселок Жандосов, Home Club </li> <li class="e-offer__feature "> Телефоны:<br> +7 (727) 308-23-63<br>+7 (747) 841-42-51<br>+7 (701) 985-90-72<br> </li> <li class="e-offer__feature ">График работы:<br> Ежедневно: круглосуточно</li> </ul>",
"features": " <p class="e-offer__features">Особенности:</p> <ul class="b-offer__features-list"> <li class="e-offer__feature">Home Club задуман и создан с любовью к природе этого края и для людей, которые ценят ее чистоту, стремятся к гармоничному здоровому отдыху.</li> <li class="e-offer__feature"> ​На территории находится 10 коттеджей (5 эконом класса и 5 Vip-коттеджей). В Vip-коттеджах есть роскошная сауна на березовых дровах, купель, мини-бар, караоке, бильярдный стол.</li> <li class="e-offer__feature"> ​В Home Club также Вам предложат спортивно-оздоровительные прогулки 3-х видов: <ul> <li>конные прогулки;</li> <li>велопрогулки;</li> <li>пешие прогулки.</li> </ul> </li> <li class="e-offer__feature"> ​Коттедж эконом класса: <ul> <li>от 4-х до 7-ми спальных мест;</li> <li>1 этаж: кухонный уголок, мини-холодильник, душевая, санузел;</li> <li>2 этаж: 2-х спальная кровать, журнальный столик, телевизор со спутниковой антенной, кондиционер, выход на балкон;</li> <li>3 этаж: 2 двухъярусные кровати, выход на балкон.</li> </ul> </li> <li class="e-offer__feature"> ​VIP-коттеджи: <ul> <li>от 4-х до 11-ти спальных мест;</li> <li>1 этаж: минихолодильник, столовый стол на 10 персон, кабельное телевидение, караоке, сауна на дровах, купель, массажный стол;</li> <li>2 этаж: бильярд-12 футов, от 2-х до 4-х комнат отдыха (в зависимости от стоимости аренды), 2 сан.узла.</li> </ul> </li> <li class="e-offer__feature">Сайт партнера: <a data-seohide-href="/deal/away/20056/" class="e-offer__feature--link seohide-link" target="_blank" rel="nofollow" title="http://www.home-club.kz/">www.home-club.kz/</a> </li> </ul>",
"imagesLinks": [
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/1_20150312023051426147565.7364.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/2_20150312023051426147565.9348.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/4_20150312093171426174997.7985.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/5_20150312093171426174997.944.jpg",
  "https://www.chocolife.me/"
],
"timeToCompletion": null,
"mainImageLink": "https://www.chocolife.me/",
"originalCouponPrice": "30 000",
"originalPrice": "30 000",
"discountPercent": "-51%",
"discountPrice": "18 000",
"discountType": "full",
"boughtCount": "1",
"sourceServiceCategories": "1 , 82 , 8 , 2",
"pageLink": "https://www.chocolife.me//20056-arenda-kottedzha-s-dvumya-spalnyami-gorki-sauna-darts-i-mnogoe-drugoe-v-prirodno-razvlekatelnom-parke-home-club-skidka-do-50",
"isArchive": 1,
"tryToUpdateCount": 0,
"viewCount": "0",
"serviceName": "Chocolife.me",
"cityName": "Алматы"
},
  {
"id": 2,
"sourceServiceId": 1,
"cityId": 1,
"createTimestamp": "2015-03-12 14:01:57",
"lastUpdateDateTime": "2016-11-01 12:39:53",
"recordHash": "dce10232f1acb53b1ee7a8bf3902e0c0",
"title": "Центр здоровья и красоты AquaBike Centre",
"shortDescription": "Тренировки по аквабайкингу или прессотерапия с инфракрасным излучением",
"longDescription": null,
"conditions": null,
"features": " <p class="e-offer__features">Особенности:</p> <ul class="b-offer__features-list"> <li class="e-offer__feature">Аквабайкинг подойдет для людей с любым уровнем физической подготовки. Для него практически нет противопоказаний.</li> <li class="e-offer__feature"> Aquabike – это:                                                 <ul> <li>подтянутый живот;</li> <li>идеальные ягодицы;</li> <li>отсутствие апельсиновой корки;</li> <li>легкость в  ногах;</li> <li>тело в тонусе;</li> <li>отсутствие задержки воды в теле;</li> <li>укрепление мускулатуры;</li> <li>отличное настроение.</li> </ul> </li> <li class="e-offer__feature"> <strong>Преимущества прессотерапии:</strong> <ul> <li>восстанавливает упругость и эластичность кожи;</li> <li>восстанавливает растянутую кожу после беременности или после существенного уменьшения веса;</li> <li>улучшает самочувствие, нормализует сон;</li> <li>обеспечивает активный кровоток;</li> <li>активизирует функции обмена веществ, выводит шлаки и токсины;</li> <li>улучшает функции пищеварения, способствует естественному снижению аппетита;</li> <li>снимает состояние общей нервозности;</li> <li>снимает боли при радикулите, артрозе, перетренировке мышц.</li> </ul> </li> <li class="e-offer__feature"> <strong>Один сеанс прессотерапии приравнивается к 1 полноценной тренировке в спортзале.</strong> </li> <li class="e-offer__feature"> В AquaBike Centre для Вас: <ul> <li>зал для занятий, рассчитанный на 2 человек;</li> <li>есть душ и раздевалки;</li> <li>тренировки, продолжительностью 45 минут.</li> </ul> </li> </ul>",
"imagesLinks": [
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20016/660x305/1_20150314013241426318344.7033.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20016/660x305/2_20150314013241426318344.8157.JPG",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20016/660x305/4_20150311053411426073981.6524.JPG",
  "https://www.chocolife.me/"
],
"timeToCompletion": null,
"mainImageLink": "https://www.chocolife.me/",
"originalCouponPrice": "3 000",
"originalPrice": "3 000",
"discountPercent": "-50%",
"discountPrice": "1 500",
"discountType": "full",
"boughtCount": "58",
"sourceServiceCategories": "1 , 68 , 36 , 2",
"pageLink": "https://www.chocolife.me//20016-novinka-iz-francii-vse-dlya-vashey-krasoty-zdorovya-i-relaksacii-trenirovki-po-akvabaykingu-a-takzhe-pressoterapiya-so-skidkoy-50-v-aquabike-centre",
"isArchive": 1,
"tryToUpdateCount": 0,
"viewCount": "0",
"serviceName": "Chocolife.me",
"cityName": "Алматы"
}, ...]


/v1/coupon/{id}
{
"id": 1,
"sourceServiceId": 1,
"cityId": 1,
"createTimestamp": "2015-03-12 14:01:57",
"lastUpdateDateTime": "2016-10-20 03:54:47",
"recordHash": "e7b01c1a69bc66e1f1a62d8fcb0825de",
"title": "Home Club",
"shortDescription": "Аренда коттеджа с двумя спальнями, горки, сауна, дартс и многое другое",
"longDescription": " Предпраздничные деньки – отличный повод для выезда за пределы города. Отдохните от машин, пробок и смога – приезжайте в семейно-оздоровительный комплекс Home Club. Именно здесь Вы можете арендовать комфортабельный коттедж со скидкой до 50%! Также к Вашим услугам футбольное поле, дартс, сауна и многое другое. Отдыхайте с душой! ",
"conditions": " <p class="e-condition__text">Условия:</p> <ul class="b-conditions-list"> <li class="e-condition">Сертификат предоставляет возможность провести время в природно-развлекательном парке Home Club.</li> <li class="e-condition"> <strong>Бонус</strong>: скидка 50% на пейнтбол - 1 500 тг. вместо 3 000 тг.</li> <li class="e-condition"> После приобретения сертификата дополнительно оплачиваются только бонусные услуги (по желанию).</li> <li class="e-condition"> Завтраки не входят в стоимость сертификата.</li> <li class="e-condition"> Коттедж рассчитан до 10 человек.</li> <li class="e-condition"> <strong>VIP-коттеджи действительны только в будние дни. А также VIP-коттеджи не действительны в праздничные дни.</strong> </li> <li class="e-condition"> Перед приобретением сертификата необходимо уточнять наличие свободных коттеджей.</li> <li class="e-condition"> <strong>Необходима предварительная запись по телефонам:</strong><br> +7 (727) 308-23-63,<br> +7 (747) 841-42-51,<br> +7 (701) 985-90-72.</li> <li class="e-condition"> <strong>Предварительная бронь не переноситься.</strong> </li> <li class="e-condition"> Бронь будет держаться в течение 2 часов, после приобретения сертификат активируется (бронь аннулируется).</li> <li class="e-condition"> Если Вы забронировали коттедж и не воспользовались услугой, стоимость купона не выплачивается и купон «сгорает».</li> <li class="e-condition"> Сертификат необходимо активировать в комплексе Home Club по адресу: Алматинская область, по верхней Каскеленской трассе, поселок Жандосов, Home Club.</li> <li class="e-condition"> Вы можете приобрести неограниченное количество сертификатов по данной акции, как для себя, так и в подарок.</li> <li class="e-condition"> <strong>Обязательно предъявляйте распечатанный сертификат при заезде.</strong> </li> <li class="e-condition"> Сертификат действителен до 12 апреля 2015 г. (включительно).</li> <li class="e-condition"> <span hashstring="deal_refunds_policy" hashtype="content">&nbsp</span> </li> <li class="e-condition"> <span hashstring="deal_standard_conditions" hashtype="content">&nbsp</span> </li> </ul> <p class="e-offer__features">Адрес</p> <ul class="b-offer__features-list"> <li class="e-offer__feature "> Алматинская область, по верхней Каскеленской трассе, поселок Жандосов, Home Club </li> <li class="e-offer__feature "> Телефоны:<br> +7 (727) 308-23-63<br>+7 (747) 841-42-51<br>+7 (701) 985-90-72<br> </li> <li class="e-offer__feature ">График работы:<br> Ежедневно: круглосуточно</li> </ul>",
"features": " <p class="e-offer__features">Особенности:</p> <ul class="b-offer__features-list"> <li class="e-offer__feature">Home Club задуман и создан с любовью к природе этого края и для людей, которые ценят ее чистоту, стремятся к гармоничному здоровому отдыху.</li> <li class="e-offer__feature"> ​На территории находится 10 коттеджей (5 эконом класса и 5 Vip-коттеджей). В Vip-коттеджах есть роскошная сауна на березовых дровах, купель, мини-бар, караоке, бильярдный стол.</li> <li class="e-offer__feature"> ​В Home Club также Вам предложат спортивно-оздоровительные прогулки 3-х видов: <ul> <li>конные прогулки;</li> <li>велопрогулки;</li> <li>пешие прогулки.</li> </ul> </li> <li class="e-offer__feature"> ​Коттедж эконом класса: <ul> <li>от 4-х до 7-ми спальных мест;</li> <li>1 этаж: кухонный уголок, мини-холодильник, душевая, санузел;</li> <li>2 этаж: 2-х спальная кровать, журнальный столик, телевизор со спутниковой антенной, кондиционер, выход на балкон;</li> <li>3 этаж: 2 двухъярусные кровати, выход на балкон.</li> </ul> </li> <li class="e-offer__feature"> ​VIP-коттеджи: <ul> <li>от 4-х до 11-ти спальных мест;</li> <li>1 этаж: минихолодильник, столовый стол на 10 персон, кабельное телевидение, караоке, сауна на дровах, купель, массажный стол;</li> <li>2 этаж: бильярд-12 футов, от 2-х до 4-х комнат отдыха (в зависимости от стоимости аренды), 2 сан.узла.</li> </ul> </li> <li class="e-offer__feature">Сайт партнера: <a data-seohide-href="/deal/away/20056/" class="e-offer__feature--link seohide-link" target="_blank" rel="nofollow" title="http://www.home-club.kz/">www.home-club.kz/</a> </li> </ul>",
"imagesLinks": [
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/1_20150312023051426147565.7364.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/2_20150312023051426147565.9348.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/4_20150312093171426174997.7985.jpg",
  "https://static.chocolife.me/static/upload/images/deal/for_deal_page/21000/20056/660x305/5_20150312093171426174997.944.jpg",
  "https://www.chocolife.me/"
],
"timeToCompletion": null,
"mainImageLink": "https://www.chocolife.me/",
"originalCouponPrice": "30 000",
"originalPrice": "30 000",
"discountPercent": "-51%",
"discountPrice": "18 000",
"discountType": "full",
"boughtCount": "1",
"sourceServiceCategories": "1 , 82 , 8 , 2",
"pageLink": "https://www.chocolife.me//20056-arenda-kottedzha-s-dvumya-spalnyami-gorki-sauna-darts-i-mnogoe-drugoe-v-prirodno-razvlekatelnom-parke-home-club-skidka-do-50",
"isArchive": 1,
"tryToUpdateCount": 0,
"viewCount": "0",
"serviceName": "Chocolife.me",
"cityName": "Алматы"
}


Ну вот как-то так… Постарался сделать так, чтобы здесь было много разных типов данных, включая подмассив с картинками.

Что же нам делать со всем этим добром? Обрабатывать конечно! Здесь и далее в этом разделе будет сухой код с комментариями, но в правильной последовательности. Так что все должно быть понятно.

Итак, для начала создадим API класс SkidKZApi и реализуем методы работы с данными сервера.

skidkzapi.h
#ifndef SKIDKZAPI_H
#define SKIDKZAPI_H

#include "apibase.h"
#include <QtQml>

class SkidKZApi : public APIBase
{
    Q_OBJECT
public:
    Q_INVOKABLE explicit SkidKZApi();

    //определяем стат. метод для регистрации апи в QML
    static void declareQML() {
        qmlRegisterType<SkidKZApi>("com.github.qtrestexample.skidkzapi", 1, 0, "SkidKZApi");
    }

    //Реализуем метод для получения данных через ReadOnly модели
    QNetworkReply *handleRequest(QString path, QStringList sort, Pagination *pagination,
                           QVariantMap filters = QVariantMap(), QStringList fields = QStringList(), QString id = 0);

    //Создаем метод для получения данных из /v1/coupon
    QNetworkReply *getCoupons(QStringList sort, Pagination *pagination,
                              QVariantMap filters = QVariantMap(), QStringList fields = QStringList());
    
    //Создаем метод для получения данных из /v1/coupon/{id}
    QNetworkReply *getCouponDetail(QString id);

    //Создаем метод для получения данных из /v1/categories
    QNetworkReply *getCategories(QStringList sort, Pagination *pagination);
};

#endif // SKIDKZAPI_H


skidkzapi.cpp
#include "skidkzapi.h"
#include <QFile>
#include <QTextStream>
#include <QUrlQuery>

SkidKZApi::SkidKZApi() : APIBase(0)
{

}

QNetworkReply *SkidKZApi::handleRequest(QString path, QStringList sort, Pagination *pagination,
                                  QVariantMap filters, QStringList fields, QString id)
{
    //принимаем запрос, сравниваем его с текстовыми константами и просто вызываем соответствующий метод
    if (path == "/v1/coupon") {
        return getCoupons(sort, pagination, filters, fields);
    }
    else if (path == "/v1/coupon/{id}") {
        return getCouponDetail(id);
    }
    else if (path == "/v1/categories") {
        return getCategories(sort, pagination);
    }
}

//Поулчаем список записей, которые будут отфильтрованы, отсортированы и разбиты по страницам в соответствии с нашими параметрами
QNetworkReply *SkidKZApi::getCoupons(QStringList sort, Pagination *pagination, QVariantMap filters, QStringList fields)
{
    //Создаем будущий запрос
    QUrl url = QUrl(baseUrl()+"/v1/coupon");
    QUrlQuery query;

    //Сортировка
    if (!sort.isEmpty()) {
        query.addQueryItem("sort", sort.join(","));
    }

    //Задаем пагинацию на основании данных модели
    switch(pagination->policy()) {
    case Pagination::PageNumber:
        query.addQueryItem("per-page", QString::number(pagination->perPage()));
        query.addQueryItem("page", QString::number(pagination->currentPage()));
        break;
    case Pagination::None:
    case Pagination::Infinity:
    case Pagination::LimitOffset:
    case Pagination::Cursor:
    default:
        break;
    }

    //задаем фильтрацию. Обратите внимание, если параметры фильтра изменятся - пагинация станет неактуальной
    if (!filters.isEmpty()) {
        QMapIterator<QString, QVariant> i(filters);
        while (i.hasNext()) {
            i.next();
            query.addQueryItem(i.key(), i.value().toString());
        }
    }

    //Проси сервер выслать нам только запрошенные поля
    if (!fields.isEmpty()) {
        query.addQueryItem("fields", fields.join(","));
    }

    //Создаем запрос
    url.setQuery(query.query());

    //Выполняем запрос на сервер методом GET
    QNetworkReply *reply = get(url);

    return reply;
}

//Запрашиваем все поля для конкретной записи
QNetworkReply *SkidKZApi::getCouponDetail(QString id)
{
    if (id.isEmpty()) {
        qDebug() << "ID is empty!";
        return 0;
    }

    //Сформировали простой запрос и отправили его на сервер методом GET
    QUrl url = QUrl(baseUrl()+"/v1/coupon/"+id);

    QNetworkReply *reply = get(url);

    return reply;
}

//Это метод для другой модели, модели категорий
QNetworkReply *SkidKZApi::getCategories(QStringList sort, Pagination *pagination)
{
    //Запрос
    QUrl url = QUrl(baseUrl()+"/v1/categories");
    QUrlQuery query;

    //Сортировка
    if (!sort.isEmpty()) {
        query.addQueryItem("sort", sort.join(","));
    }

    //Пагинация
    switch(pagination->policy()) {
    case Pagination::PageNumber:
        query.addQueryItem("per-page", QString::number(pagination->perPage()));
        query.addQueryItem("page", QString::number(pagination->currentPage()));
        break;
    case Pagination::None:
    case Pagination::Infinity:
    case Pagination::LimitOffset:
    case Pagination::Cursor:
    default:
        break;
    }

    url.setQuery(query.query());

    QNetworkReply *reply = get(url);

    return reply;
}


API класс готов, в нескольких простых методов мы реализовали всю работу с сервером, нужную нам на данный момент. Далее, рассмотрим два варианта использования модели. Для категорий мы будем использовать встроенную в библиотеку модель JsonRestListModel, а для купонов — модель унаследованную от AbstractJsonListModel.

couponmodel.h
#ifndef COUPONMODEL_H
#define COUPONMODEL_H

#include "abstractjsonrestlistmodel.h"
#include "api/skidkzapi.h"

class CouponModel : public AbstractJsonRestListModel
{
    Q_OBJECT

public:
    explicit CouponModel(QObject *parent = 0);

    //регистрация модели в QML (функцию надо вызвать в main.cpp до загрузки QML)
    static void declareQML() {
        AbstractJsonRestListModel::declareQML();
        qmlRegisterType<CouponModel>("com.github.qtrestexample.coupons", 1, 0, "CouponModel");
    }

protected:
    //методы получения данных из API
    QNetworkReply *fetchMoreImpl(const QModelIndex &parent);
    QNetworkReply *fetchDetailImpl(QString id);

    //Метод предобработки каждой записи
    QVariantMap preProcessItem(QVariantMap item);
};

#endif // COUPONMODEL_H


couponmodel.cpp
#include "couponmodel.h"

CouponModel::CouponModel(QObject *parent) : AbstractJsonRestListModel(parent)
{

}

QNetworkReply *CouponModel::fetchMoreImpl(const QModelIndex &parent)
{
    Q_UNUSED(parent)
    //Просто вызываем нужный API метод
    return static_cast<SkidKZApi *>(apiInstance())->getCoupons(sort(), pagination(), filters(), fields());
}

QNetworkReply *CouponModel::fetchDetailImpl(QString id)
{
    //Просто вызываем нужный API метод
    return static_cast<SkidKZApi *>(apiInstance())->getCouponDetail(id);
}

QVariantMap CouponModel::preProcessItem(QVariantMap item)
{
    //Мы хотим преобразовать выводимое значение поля createTimestamp
    QDate date = QDateTime::fromString(item.value("createTimestamp").toString(), "yyyy-MM-dd hh:mm:ss").date();
    item.insert("createDate", date.toString("dd.MM.yyyy"));

    //А также - поле originalCouponPrice
    QString originalCouponPrice = item.value("originalCouponPrice").toString().trimmed();
    if (originalCouponPrice.isEmpty()) { originalCouponPrice = "?"; }
    QString discountPercent = item.value("discountPercent").toString().trimmed().remove("—").remove("-").remove("%");
    if (discountPercent.isEmpty()) { discountPercent = "?"; }
    QString originalPrice = item.value("originalPrice").toString().trimmed();
    if (originalPrice.isEmpty()) { originalPrice = "?"; }
    QString discountPrice = item.value("discountPrice").toString().remove("тг.").trimmed();
    if (discountPrice.isEmpty()) { discountPrice = "?"; }

    //и добавить новое поле discountString, которого вообще нет в API
    QString discountType = item.value("discountType").toString();
    QString discountString = tr("Undefined Type");
    if (discountType == "freeCoupon" || discountType == "coupon") {
        discountString = tr("Coupon: %1. Discount: %2%").arg(originalCouponPrice).arg(discountPercent);
    } else if (discountType == "full") {
        discountString = tr("Cost: %1. Certificate: %2. Discount: %3%").arg(originalPrice).arg(discountPrice).arg(discountPercent);
    }

    item.insert("discountString", discountString);

    return item;
}


Готово! У нас есть все необходимое для получения данных, поря связать это с GUI частью.

Для начала, не забудьте вызвать методы declareQML в main.cpp, пример будет в исходниках.

Ну а далее — как обычно создаем QML приложение и используем наши модели в качестве источника данных:

somewhere.qml
...
import com.github.qtrestexample.skidkzapi 1.0
import com.github.qtrest.jsonrestlistmodel 1.0
import com.github.qtrest.pagination 1.0
import com.github.qtrest.requests 1.0
...
    //API объект, один на все приложение, токен авторизации - рабочий =)
    SkidKZApi {
        id: skidKZApi

        baseUrl: "http://api.skid.kz"

        authTokenHeader: "Authorization"
        authToken: "Bearer 8aef452ee3b32466209535b96d456b06"

        Component.onCompleted: console.log("completed!");
    }

    //Модель категорий, пример ReadOnly модели
    //Как видим, для этой модели мы вообще не написали ни строки лишнего кода - только запросили ее с сервера, а библиотека сама все распарсила
    JsonRestListModel {
        id: categoriesRestModel
        api: skidKZApi

        idField: 'id'

        requests {
            get: "/v1/categories"
        }

        sort: ['categoryName']

        pagination {
            policy: Pagination.PageNumber
            perPage: 20
            currentPageHeader: "X-Pagination-Current-Page"
            totalCountHeader: "X-Pagination-Total-Count"
            pageCountHeader: "X-Pagination-Page-Count"
        }

        Component.onCompleted: { console.log(pagination.perPage); reload(); }
    }

    //Созданная нами CouponModel модель, здесь мы не задаем requests, т.к. вызовы идут через fetchMoreImpl.
    CouponModel {
        id: coupons;
        api: skidKZApi

        filters: {'isArchive': '0'}
        idField: 'id'
        fields: ['id','title','sourceServiceId','imagesLinks', 
                   'mainImageLink','pageLink','cityId','boughtCount',
                   'shortDescription','createTimestamp', 'serviceName', 
                   'discountType', 'originalCouponPrice', 'originalPrice',
                    'discountPercent', 'discountPrice']
        sort: ['-id']

        pagination {
            policy: Pagination.PageNumber
            perPage: 20
            currentPageHeader: "X-Pagination-Current-Page"
            totalCountHeader: "X-Pagination-Total-Count"
            pageCountHeader: "X-Pagination-Page-Count"
        }

        Component.onCompleted: { console.log(pagination.perPage); reload(); }
    }


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

Исходный код и приложение-пример


Ну и собственно перейдем к самому интересному. Весь проект лежит на GitHub по следующим адресам:


Надеюсь, все вышеописанное окажется для кого-то полезным и позволит не тратить время на разработку API клиентов для собственных нужд.

PS: Жаль, на хабре уже года три не бывает дискуссий под техническими статьями, так что если вас заинтересовала тема — обязательно пишите комменты, вдруг я что-то упустил в реализации? =)
Only registered users can participate in poll. Log in, please.
Понравилось?
71.93% Да 41
5.26% Нет 3
22.81% Результаты 13
57 users voted. 31 users abstained.
Tags:
Hubs:
+25
Comments 19
Comments Comments 19

Articles