Увидел сегодня в ленте статью и вспомнил, что хотел ведь про свой проект пару строк на Хабр написать.
В общем, некоторое время я работал техлидом с программистами iOS/Android, которые много использовали в своем коде API на Django/Yii2/проприетарщине. И посмотрев со стороны на инструменты, имеющиеся у них для работы с REST API, я решил нечто подобное добавить и в Qt, т.к. нормальных средств по работе с REST с использованием Qt моделей не существовало.
Сказано — сделано. На картинке ниже получившаяся в итоге схема, а под ней, собственно описание идеи, архитектуры и краткая инструкция по использованию.
Итак, вот что мы обсудим:
- Идея и фичи
- Архитектура
- Пример использования
- Исходный код и приложение-пример
Идея и фичи
По своей сути, любое нормально спроектированное 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.
Рассмотрим разные сценарии использования своего 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 и создать собственную модель со всеми необходимыми методами по манипуляции данными. Подробнее поговорим об этом в примере использования.
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 методы и данные, которые они возвращают.
[{
"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
}
, ...]
[{
"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"> </span> </li> <li class="e-condition"> <span hashstring="deal_standard_conditions" hashtype="content"> </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": "Алматы"
}, ...]
{
"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"> </span> </li> <li class="e-condition"> <span hashstring="deal_standard_conditions" hashtype="content"> </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 и реализуем методы работы с данными сервера.
#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
#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.
#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
#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 приложение и используем наши модели в качестве источника данных:
...
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 по следующим адресам:
- https://github.com/kafeg/qtrest — библиотека.
- https://github.com/kafeg/qtrest-example — демо приложение.
Надеюсь, все вышеописанное окажется для кого-то полезным и позволит не тратить время на разработку API клиентов для собственных нужд.
PS: Жаль, на хабре уже года три не бывает дискуссий под техническими статьями, так что если вас заинтересовала тема — обязательно пишите комменты, вдруг я что-то упустил в реализации? =)
Автор: vitaly_KF