Qt / QML REST Client

Побачив сьогодні у стрічці статті і згадав, що хотів адже про свій проект пару рядків на Хабр написати.

Загалом, якийсь час я працював техлидом з програмістами 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.

Розглянемо різні сценарії свого 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 методи та дані, які вони повертають.

/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: Шкода, на хабре вже роки три не буває дискусій під технічними статтями, так що якщо вас зацікавила тема — обов'язково пишіть коменти, раптом я щось упустив в реалізації? =)

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.