diff --git a/API/CMakeLists.txt b/API/CMakeLists.txt index 77e312a..c2f1ee4 100644 --- a/API/CMakeLists.txt +++ b/API/CMakeLists.txt @@ -5,12 +5,16 @@ set(HEADERS api.h codes.h finalaction.h + helpers.h ) set(SOURCES api.cpp codes.cpp finalaction.cpp + helpers.cpp ) target_sources(magpie PRIVATE ${SOURCES}) + +add_subdirectory(models) diff --git a/API/api.cpp b/API/api.cpp index 9537778..567bad1 100644 --- a/API/api.cpp +++ b/API/api.cpp @@ -9,6 +9,7 @@ #include "codes.h" #include "finalaction.h" +#include "helpers.h" constexpr const char* json = "application/json"; constexpr const char* urlEncoded = "application/x-www-form-urlencoded"; @@ -41,11 +42,13 @@ API::API(const QUrl& address, QObject* parent): accessToken(), renewToken(), firstPoll(), - pollReply() + pollReply(), + assets() { firstPoll.setSingleShot(true); firstPoll.setInterval(2000); connect(&firstPoll, &QTimer::timeout, this, &API::onFirstPollSuccess); + connect(&assets, &Models::Assets::requestAssets, this, &API::requestAssets); } QUrl API::getAddress() const { @@ -134,24 +137,6 @@ QUrl API::createUrl(const QString &path) const { return url; } -void API::sendPoll() { - if (accessToken.isEmpty()) - throw std::runtime_error("Can not start polling: access token is empty"); - - QByteArray authorizationHeader = "Bearer " + accessToken.toUtf8(); - QNetworkRequest request(createUrl("/poll")); - request.setHeader(QNetworkRequest::ContentTypeHeader, json); - request.setRawHeader("Authorization", authorizationHeader); - request.setTransferTimeout(30000); - - pollReply = std::unique_ptr(network.get(request)); - connect( - pollReply.get(), &QNetworkReply::finished, - this, &API::onPollFinished, - Qt::QueuedConnection - ); -} - void API::test(const QString& path, const QJSValue& finished) { qDebug() << "Testing" << path; if (state == Offline) @@ -209,25 +194,6 @@ void API::sendRegister(const QString& login, const QString& password, const QJSV ); } -void API::sendLogin(const QString& login, const QString& password, const QJSValue& finished) { - qDebug() << "Logging in..."; - if (state != NotAuthenticated) - return callCallback(finished, "Can not register in current state"); - - QUrlQuery params({ - {"login", login}, - {"password", password} - }); - - QNetworkRequest request(createUrl("/login")); - request.setHeader(QNetworkRequest::ContentTypeHeader, urlEncoded); - setState(Authenticating); - QNetworkReply* reply = network.post(request, params.toString(QUrl::FullyEncoded).toUtf8()); - connect(reply, &QNetworkReply::finished, - std::bind(&API::onLoginFinished, this, reply, finished) - ); -} - void API::onRegisterFinished(QNetworkReply* reply, const QJSValue& finished) const { std::unique_ptr rpl(reply); QNetworkReply::NetworkError error = reply->error(); @@ -251,6 +217,25 @@ void API::onRegisterFinished(QNetworkReply* reply, const QJSValue& finished) con callCallback(finished, detail, {QJSValue(success)}); } +void API::sendLogin(const QString& login, const QString& password, const QJSValue& finished) { + qDebug() << "Logging in..."; + if (state != NotAuthenticated) + return callCallback(finished, "Can not register in current state"); + + QUrlQuery params({ + {"login", login}, + {"password", password} + }); + + QNetworkRequest request(createUrl("/login")); + request.setHeader(QNetworkRequest::ContentTypeHeader, urlEncoded); + setState(Authenticating); + QNetworkReply* reply = network.post(request, params.toString(QUrl::FullyEncoded).toUtf8()); + connect(reply, &QNetworkReply::finished, + std::bind(&API::onLoginFinished, this, reply, finished) + ); +} + void API::onLoginFinished(QNetworkReply* reply, const QJSValue& finished) { State state = NotAuthenticated; FinalAction action([this, &state]() { //this should be executed on leaving the function in any way @@ -289,6 +274,57 @@ void API::onLoginFinished(QNetworkReply* reply, const QJSValue& finished) { startPolling(); } +void API::addAsset(const QString& title, const QString& icon, const QJSValue &finished) { + qDebug() << "Adding asset..."; + if (state != Authenticated) + return callCallback(finished, "Can not add assets in current state"); + + QUrlQuery params({ + {"title", title}, + {"icon", icon} + }); + + QNetworkRequest request(createUrl("/addAsset")); + request.setHeader(QNetworkRequest::ContentTypeHeader, urlEncoded); + QNetworkReply* reply = network.post(request, params.toString(QUrl::FullyEncoded).toUtf8()); + connect(reply, &QNetworkReply::finished, + std::bind(&API::onAssetAdded, this, reply, finished) + ); +} + +void API::onAssetAdded(QNetworkReply *reply, const QJSValue &finished) { + std::unique_ptr rpl(reply); + QNetworkReply::NetworkError error = reply->error(); + std::optional data = readResult(reply); + + if (error != QNetworkReply::NoError) + return callCallback(finished, reply->errorString()); + + callCallback(finished, QString(), {QJSValue(true)}); +} + +void API::sendPoll(bool clear) { + if (accessToken.isEmpty()) + throw std::runtime_error("Can not start polling: access token is empty"); + + QUrl url = createUrl("/poll"); + if (clear) + url.setQuery(QUrlQuery({{"clearCache", "all"}})); + + QByteArray authorizationHeader = "Bearer " + accessToken.toUtf8(); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, json); + request.setRawHeader("Authorization", authorizationHeader); + request.setTransferTimeout(30000); + + pollReply = std::unique_ptr(network.get(request)); + connect( + pollReply.get(), &QNetworkReply::finished, + this, &API::onPollFinished, + Qt::QueuedConnection + ); +} + void API::onPollFinished() { firstPoll.stop(); QNetworkReply::NetworkError error = pollReply->error(); @@ -304,11 +340,23 @@ void API::onPollFinished() { else qDebug() << "Poll finished: " + detail; + bool clear = false; switch (code) { - case Codes::Poll::success: + case Codes::Poll::success: { + QVariantMap::ConstIterator itr = data->constFind("data"); + if (itr == data->constEnd()) + qDebug("received a poll that claimed to have some updates, but the \"data\" field is abscent"); + + const QVariant& vdata = itr.value(); + if (vdata.canConvert()) + clear = handleChanges(qast(itr.value())); + else + qDebug("received a poll that claimed to have some updates, but the \"data\" field is not an object"); + } //todo handle the result case Codes::Poll::timeout: - return sendPoll(); + setState(Authenticated); + return sendPoll(clear); case Codes::Poll::tokenProblem: case Codes::Poll::replace: case Codes::Poll::unknownError: //todo this one doesn't actually mean that we can't work for now, the network may be temporarily down or something @@ -317,10 +365,112 @@ void API::onPollFinished() { } } +bool API::handleChanges(const QVariantMap& changes) { + QVariantMap::ConstIterator itr = changes.constFind("system"); + if (itr != changes.constEnd()) { + const QVariant& vsys = itr.value(); + if (vsys.canConvert()) { + const QVariantMap& sys = qast(vsys); + QVariantMap::ConstIterator invItr = sys.constFind("invalidate"); + if (invItr != sys.constEnd()) { + const QVariant& vinv = invItr.value(); + if (vinv.canConvert() && vinv.toBool()) + resetAllModels(); + } + } + } + + itr = changes.constFind("assets"); + if (itr != changes.constEnd()) { + const QVariant& vassets = itr.value(); + if (vassets.canConvert()) { + const QVariantMap& assets = qast(vassets); + QVariantMap::ConstIterator aItr = assets.constFind("invalidate"); + if (aItr != assets.constEnd()) { + const QVariant& vinv = aItr.value(); + if (vinv.canConvert() && vinv.toBool()) + API::assets.clear(); + } + + aItr = assets.constFind("added"); + if (aItr != assets.constEnd()) { + const QVariant& vadd = aItr.value(); + std::deque added; + if (!Models::Assets::deserialize(qast(itr.value()), added)) + qDebug() << "Error deserializng added assets"; + else + API::assets.addAssets(added); + } + } + } + + return true; +} + +void API::resetAllModels() { + assets.clear(); +} + void API::onFirstPollSuccess() { setState(Authenticated); } +void API::requestAssets() { + if (state != Authenticated) { + qDebug() << "An attempt to request assets on unauthenticated state"; + assets.receivedAssets({}); + return; + } + qDebug() << "Requesting assets..."; + + QUrl url = createUrl("/listAssets"); + QByteArray authorizationHeader = "Bearer " + accessToken.toUtf8(); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, json); + request.setRawHeader("Authorization", authorizationHeader); + + QNetworkReply* reply = network.get(request); + connect( + pollReply.get(), &QNetworkReply::finished, + this, std::bind(&API::responseAssets, this, reply) + ); +} + +void API::responseAssets(QNetworkReply *reply) { + std::deque result; + FinalAction action([this, &result]() { + assets.receivedAssets(result); + }); + + std::unique_ptr rpl(reply); + QNetworkReply::NetworkError error = reply->error(); + + if (error != QNetworkReply::NoError) { + qDebug() << "Error receiving assets:" << reply->errorString(); + return; + } + + std::optional data = readResult(reply); + if (!data) { + qDebug() << "Error receiving assets: bad data"; + return; + } + + QVariantMap::ConstIterator itr = data->find("assets"); + if (itr == data->end() || !itr->canConvert()) { + qDebug() << "Error receiving assets: assets are missing or not in an array"; + return; + } + + if (!Models::Assets::deserialize(qast(itr.value()), result)) { + qDebug() << "Error deserializng assets"; + result.clear(); + } + + qDebug() << "Assets successfully received"; + //final action goes automatically +} + void API::callCallback(const QJSValue& callback, const QString& error, const QJSValueList& arguments) const { if (callback.isCallable()) { if (error.isEmpty()) diff --git a/API/api.h b/API/api.h index d46c1a4..0e58143 100644 --- a/API/api.h +++ b/API/api.h @@ -15,6 +15,8 @@ #include #include +#include "models/assets.h" + class Root; class API : public QObject { friend class Root; @@ -51,15 +53,20 @@ public slots: void test(const QString& path, const QJSValue& finished = QJSValue()); void sendRegister(const QString& login, const QString& password, const QJSValue& finished = QJSValue()); void sendLogin(const QString& login, const QString& password, const QJSValue& finished = QJSValue()); + void addAsset(const QString& title, const QString& icon, const QJSValue& finished = QJSValue()); private slots: void onTestFinished(QNetworkReply* reply, const QUrl& addr, const QJSValue& finished); void onRegisterFinished(QNetworkReply* reply, const QJSValue& finished) const; void onLoginFinished(QNetworkReply* reply, const QJSValue& finished); + void onAssetAdded(QNetworkReply* reply, const QJSValue& finished); void onPollFinished(); void onFirstPollSuccess(); + void requestAssets(); + void responseAssets(QNetworkReply* reply); + private: void callCallback(const QJSValue& callback, const QString& error = QString(), const QJSValueList& arguments = QJSValueList()) const; void setAddress(const QUrl& path); @@ -67,7 +74,9 @@ private: static bool validateResponse(const std::optional& data, const std::map& structure); void setState(State newState); QUrl createUrl(const QString& path) const; - void sendPoll(); + void sendPoll(bool clear = false); + bool handleChanges(const QVariantMap& changes); + void resetAllModels(); private: QUrl address; @@ -77,4 +86,7 @@ private: QString renewToken; QTimer firstPoll; std::unique_ptr pollReply; + +public: + Models::Assets assets; }; diff --git a/API/helpers.cpp b/API/helpers.cpp new file mode 100644 index 0000000..86cee1f --- /dev/null +++ b/API/helpers.cpp @@ -0,0 +1,5 @@ +//SPDX-FileCopyrightText: 2023 Yury Gubich +//SPDX-License-Identifier: GPL-3.0-or-later + +#include "helpers.h" + diff --git a/API/helpers.h b/API/helpers.h new file mode 100644 index 0000000..d010b96 --- /dev/null +++ b/API/helpers.h @@ -0,0 +1,15 @@ +//SPDX-FileCopyrightText: 2023 Yury Gubich +//SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +template +const T& qast(const QVariant& variant) { + if (variant.userType() == qMetaTypeId()) + return *reinterpret_cast(variant.data()); + + throw std::runtime_error("An usuccessfull qast"); +} diff --git a/API/models/CMakeLists.txt b/API/models/CMakeLists.txt new file mode 100644 index 0000000..4284a3e --- /dev/null +++ b/API/models/CMakeLists.txt @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2023 Yury Gubich +# SPDX-License-Identifier: GPL-3.0-or-later + +set(HEADERS + assets.h +) + +set(SOURCES + assets.cpp +) + +target_sources(magpie PRIVATE ${SOURCES}) diff --git a/API/models/assets.cpp b/API/models/assets.cpp new file mode 100644 index 0000000..cad42f9 --- /dev/null +++ b/API/models/assets.cpp @@ -0,0 +1,138 @@ +//SPDX-FileCopyrightText: 2023 Yury Gubich +//SPDX-License-Identifier: GPL-3.0-or-later + +#include "assets.h" + +#include "../helpers.h" + +Models::Assets::Assets (QObject* parent): + QAbstractListModel(parent), + records(), + state(State::initial) +{} + +void Models::Assets::clear () { + beginResetModel(); + records.clear(); + endResetModel(); +} + +void Models::Assets::addAsset (const Asset& asset) { + QModelIndex index = getIndex(asset.id); + if (index.isValid()) + throw std::runtime_error("An attempt to insert a duplicating Asset to an asset model"); + + beginInsertRows(QModelIndex(), records.size(), records.size() + 1); + records.push_back(asset); + endInsertRows(); +} + +void Models::Assets::addAssets (const std::deque& assets) { + if (assets.empty()) + return; + + for (const Asset& asset : assets) + if (getIndex(asset.id).isValid()) + throw std::runtime_error("An attempt to insert a duplicating Asset to an asset model (bulk)"); + + beginInsertRows(QModelIndex(), records.size(), records.size() + assets.size()); + for (const Asset& asset : assets) + records.push_back(asset); + + endInsertRows(); +} + +void Models::Assets::deleteAsset (unsigned int id) { + QModelIndex index = getIndex(id); + if (!index.isValid()) + throw std::runtime_error("An attempt to insert to delete non existing Asset from asset model"); + + int row = index.row(); + beginRemoveRows(QModelIndex(), row, row + 1); + records.erase(records.begin() + row); + if (state == State::syncronized) //give a second thought + state = State::initial; + + endRemoveRows(); +} + +int Models::Assets::rowCount (const QModelIndex& parent) const { + //For list models only the root node (an invalid parent) should return the + //list's size. For all other (valid) parents, rowCount() should return 0 so + //that it does not become a tree model. + if (parent.isValid()) + return 0; + + return records.size(); +} + +QHash Models::Assets::roleNames () const { + static const QHash roleNames{ + {Title, "title"}, {Icon, "icon"}, {Balance, "balance"}, {Archived, "archived"} + }; + return roleNames; +} + +bool Models::Assets::canFetchMore (const QModelIndex& parent) const { + return state == State::initial; +} + +void Models::Assets::fetchMore (const QModelIndex& parent) { + if (state != State::initial) + return; + + state = State::requesting; +} + +QVariant Models::Assets::data (const QModelIndex& index, int role) const { + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + if (row >= 0 && row < records.size()) { + switch (role) { + case Qt::DisplayRole: + case Title: + return records[row].title; + case Icon: + return records[row].icon; + case Balance: + return records[row].balance; + case Archived: + return records[row].archived; + } + } + + return QVariant(); +} + +bool Models::Assets::deserialize (const QVariantList& from, std::deque& out) { + for (const QVariant& item : from) { + if (!item.canConvert()) + return false; + + const QVariantMap& ser = qast(item); + Asset& asset = out.emplace_back(); + asset.title = ser.value("title").toString(); + asset.icon = ser.value("icon").toString(); + asset.archived = ser.value("archived").toBool(); + asset.id = ser.value("archived").toUInt(); + } + + return true; +} + +void Models::Assets::receivedAssets (const std::deque& assets) { + beginResetModel(); + records = assets; + state = State::syncronized; + endResetModel(); +} + +QModelIndex Models::Assets::getIndex (unsigned int id) const { + for (std::size_t i = 0; i < records.size(); ++i) { + if (records[i].id == id) + return createIndex(i, 0, &records[i]); + } + return QModelIndex(); +} diff --git a/API/models/assets.h b/API/models/assets.h new file mode 100644 index 0000000..0daf656 --- /dev/null +++ b/API/models/assets.h @@ -0,0 +1,71 @@ +//SPDX-FileCopyrightText: 2023 Yury Gubich +//SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include +#include + +namespace Models { +struct Asset { + unsigned int id; + QString title; + QString icon; + double balance; + bool archived; + unsigned int currency; +}; + +class Assets : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + +public: + explicit Assets (QObject* parent = nullptr); + + enum Roles { + Title = Qt::UserRole + 1, + Icon, + Balance, + Archived + }; + + void clear(); + void addAsset(const Asset& asset); + void addAssets(const std::deque& assets); + void deleteAsset(unsigned int id); + + //Basic functionality: + int rowCount (const QModelIndex& parent = QModelIndex()) const override; + QHash roleNames () const override; + + //Fetch data dynamically: + bool canFetchMore (const QModelIndex& parent) const override; + void fetchMore (const QModelIndex& parent) override; + QVariant data (const QModelIndex& index, int role = Qt::DisplayRole) const override; + + static bool deserialize(const QVariantList& from, std::deque& out); + +signals: + void requestAssets(); + +public slots: + void receivedAssets(const std::deque& assets); + +private: + QModelIndex getIndex(unsigned int id) const; + +private: + enum class State { + initial, + requesting, + syncronized + }; + + State state; + std::deque records; +}; +}