// SPDX-FileCopyrightText: 2023 Yury Gubich // SPDX-License-Identifier: GPL-3.0-or-later #include "api.h" #include #include #include #include #include "codes.h" #include "finalaction.h" #include "helpers.h" constexpr const char* json = "application/json"; constexpr const char* urlEncoded = "application/x-www-form-urlencoded"; const std::map testStructure({ {"type", QMetaType::QString}, {"version", QMetaType::QString}, }); const std::map resultStructure({ {"result", QMetaType::LongLong}, }); const std::map tokensStructure({ {"accessToken", QMetaType::QString}, {"renewToken", QMetaType::QString} }); struct NetworkReplyDeleter { void operator () (QNetworkReply* reply) { reply->deleteLater(); } }; API::API(const QUrl& address, QObject* parent): QObject(parent), address(address), network(), state(NoServer), accessToken(), renewToken(), firstPoll(), 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 { return address; } API::State API::getState() const { return state; } void API::setTokens(const QString access, const QString &renew) { accessToken = access; renewToken = renew; setState(Authenticating); startPolling(); } void API::startPolling() { qDebug() << "Starting polling..."; if (state != Authenticating) throw std::runtime_error("Can not start polling in this state: " + std::to_string(state)); sendPoll(); firstPoll.start(); } void API::setAddress(const QUrl& path) { if (address == path) return; if (state == Authenticated) { //do something } address = path; emit addressChanged(address); setState(address.isEmpty() ? NoServer : NotAuthenticated); } std::optional API::readResult(QNetworkReply* reply) { QVariant contentType = reply->header(QNetworkRequest::ContentTypeHeader); if (! contentType.isValid() || contentType.userType() != QMetaType::QString || contentType.toString() != json ) return std::nullopt; QByteArray data = reply->readAll(); QJsonDocument document = QJsonDocument::fromJson(data); if (!document.isObject()) return std::nullopt; QJsonObject object = document.object(); return object.toVariantMap(); } bool API::validateResponse(const std::optional& data, const std::map& structure) { if (!data.has_value()) return false; for (const std::pair& pair : structure) { QVariantMap::ConstIterator itr = data->find(pair.first); if (itr == data->end()) return false; if (itr->userType() != pair.second) return false; } return true; } void API::setState(State newState) { if (newState == state) return; state = newState; emit stateChanged(state); } QUrl API::createUrl(const QString &path) const { QString startingPath = address.path(); QUrl url = address; url.setPath(startingPath + path); return url; } void API::test(const QString& path, const QJSValue& finished) { qDebug() << "Testing" << path; if (state == Offline) return callCallback(finished, "Need to be online to test"); QUrl address(path); QNetworkRequest request(path + "/info"); request.setHeader(QNetworkRequest::ContentTypeHeader, json); QNetworkReply* reply = network.get(request); connect(reply, &QNetworkReply::finished, std::bind(&API::onTestFinished, this, reply, address, finished) ); } void API::onTestFinished(QNetworkReply* reply, const QUrl& addr, const QJSValue& finished) { std::unique_ptr rpl(reply); QNetworkReply::NetworkError error = reply->error(); if (error != QNetworkReply::NoError) return callCallback(finished, reply->errorString()); std::optional data = readResult(reply); if (!validateResponse(data, testStructure)) return callCallback(finished, "Malformed response"); QString type = data->value("type").toString(); if (type != "pica") return callCallback(finished, "server of this type (" + type + ") is not supported"); QString version = data->value("version").toString(); if (version != "0.0.1") return callCallback(finished, "server of this version (" + version + ") is not supported"); callCallback(finished, QString(), {QJSValue(true)}); address = ""; //to provoke singal change even if it's the same server setAddress(addr); } void API::sendRegister(const QString& login, const QString& password, const QJSValue& finished) { qDebug() << "Registering..."; if (state != NotAuthenticated) return callCallback(finished, "Can not register in current state"); QUrlQuery params({ {"login", login}, {"password", password} }); QNetworkRequest request(createUrl("/register")); request.setHeader(QNetworkRequest::ContentTypeHeader, urlEncoded); QNetworkReply* reply = network.post(request, params.toString(QUrl::FullyEncoded).toUtf8()); connect(reply, &QNetworkReply::finished, std::bind(&API::onRegisterFinished, this, reply, finished) ); } void API::onRegisterFinished(QNetworkReply* reply, const QJSValue& finished) const { std::unique_ptr rpl(reply); QNetworkReply::NetworkError error = reply->error(); std::optional data = readResult(reply); std::optional code; QString detail; bool success = false; if (validateResponse(data, resultStructure)) { code = Codes::convertRegister(data->value("result").toInt()); success = code.value() == Codes::Register::success; if (!success) detail = Codes::description(code.value()); } if (error != QNetworkReply::NoError) return callCallback(finished, reply->errorString() + (success ? "" : ": " + detail)); if (!code) return callCallback(finished, "Malformed result"); 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 setState(state); //setting the state as a result of this object destruction }); //this way any error will result in NotAuthenticated, and on success it should be Authenticated std::unique_ptr rpl(reply); QNetworkReply::NetworkError error = reply->error(); std::optional data = readResult(reply); std::optional code; QString detail; bool success = false; if (validateResponse(data, resultStructure)) { code = Codes::convertLogin(data->value("result").toInt()); success = code.value() == Codes::Login::success; if (!success) detail = Codes::description(code.value()); } if (error != QNetworkReply::NoError) return callCallback(finished, reply->errorString() + (success ? "" : ": " + detail)); if (!code) return callCallback(finished, "Malformed result"); if (!validateResponse(data, tokensStructure)) return callCallback(finished, "Malformed result: missing tokens"); callCallback(finished, detail, {QJSValue(success)}); state = Authenticating; accessToken = data->value("accessToken").toString(); renewToken = data->value("renewToken").toString(); emit storeTokens(accessToken, renewToken); 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}, {"currency", "1"} }); QNetworkRequest request(createUrl("/addAsset")); request.setHeader(QNetworkRequest::ContentTypeHeader, urlEncoded); request.setRawHeader("Authorization", "Bearer " + accessToken.toUtf8()); 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(); qDebug() << error; 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(); std::optional data = readResult(pollReply.get()); Codes::Poll code = Codes::Poll::unknownError; if (validateResponse(data, resultStructure)) code = Codes::convertPoll(data->value("result").toInt()); else qDebug() << ""; QString detail = Codes::description(code); if (error != QNetworkReply::NoError) qDebug() << pollReply->errorString() + ": " + detail; else qDebug() << "Poll finished: " + detail; bool clear = false; switch (code) { 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: 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 pollReply.reset(); return setState(NotAuthenticated); } } 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->constEnd() || !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()) callback.call(QJSValueList({QJSValue(QJSValue::NullValue)}) + arguments); else callback.call({QJSValue(error)}); } }