diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4b13d459..03073128 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,3 +37,5 @@ endif() if (NETWORK) add_subdirectory(network) endif() + +add_subdirectory(http) diff --git a/src/http/CMakeLists.txt b/src/http/CMakeLists.txt new file mode 100644 index 00000000..f47f3f72 --- /dev/null +++ b/src/http/CMakeLists.txt @@ -0,0 +1,21 @@ +qt_add_library(quickshell-http STATIC + client.cpp + response.cpp +) + +qt_add_qml_module(quickshell-http + URI Quickshell.Http + VERSION 0.1 + DEPENDENCIES QtQml +) + +install_qml_module(quickshell-http) + +target_link_libraries(quickshell-http PRIVATE + Qt::Qml + Qt::Network +) + +qs_module_pch(quickshell-http) + +target_link_libraries(quickshell PRIVATE quickshell-httpplugin) diff --git a/src/http/client.cpp b/src/http/client.cpp new file mode 100644 index 00000000..fa5edf07 --- /dev/null +++ b/src/http/client.cpp @@ -0,0 +1,221 @@ +#include "client.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "response.hpp" + +namespace { + +QNetworkRequest createRequest(const QString& url, const QJSValue& headers) { + QNetworkRequest req; + + req.setUrl(QUrl(url)); + + if (headers.isObject()) { + QJSValueIterator iter(headers); + while (iter.hasNext()) { + iter.next(); + req.setRawHeader(iter.name().toUtf8(), iter.value().toString().toUtf8()); + } + } + + return req; +} + +} // namespace + +namespace qs::http { + +HttpClient::HttpClient(QObject* parent) + : QObject(parent) + , mManager(new QNetworkAccessManager(this)) {} + +void HttpClient::connectReply(QNetworkReply* reply, const QJSValue& callback, int timeout) { + if (timeout > 0) { + auto* timer = new QTimer(reply); + timer->setSingleShot(true); + timer->setInterval(timeout); + connect(timer, &QTimer::timeout, reply, &QNetworkReply::abort); + timer->start(); + } + + connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); + + QJSEngine* engine = qjsEngine(this); + if (!engine || !callback.isCallable()) { + return; + } + + connect(reply, &QNetworkReply::finished, this, [reply, engine, callback]() mutable { + auto* resp = new HttpResponse(reply); + auto jsResp = engine->newQObject(resp); + QJSEngine::setObjectOwnership(resp, QJSEngine::JavaScriptOwnership); + + callback.call(QJSValueList() << jsResp); + }); +} + +void HttpClient::request( + const QString& url, + const QString& verb, + const QJSValue& options, + const QJSValue& callback +) { + QJSValue headers; + QByteArray body; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("body")) { + auto bodyProp = options.property("body"); + body = bodyProp.toVariant().toByteArray(); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->sendCustomRequest(req, verb.toUtf8(), body); + + this->connectReply(reply, callback, timeout); +} + +void HttpClient::get(const QString& url, const QJSValue& options, const QJSValue& callback) { + QJSValue headers; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->get(req); + + this->connectReply(reply, callback, timeout); +} + +void HttpClient::post( + const QString& url, + const QVariant& body, + const QJSValue& options, + const QJSValue& callback +) { + auto bodyBytes = body.toByteArray(); + QJSValue headers; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->post(req, bodyBytes); + + this->connectReply(reply, callback, timeout); +} + +void HttpClient::put( + const QString& url, + const QVariant& body, + const QJSValue& options, + const QJSValue& callback +) { + auto bodyBytes = body.toByteArray(); + QJSValue headers; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->put(req, bodyBytes); + + this->connectReply(reply, callback, timeout); +} + +void HttpClient::del(const QString& url, const QJSValue& options, const QJSValue& callback) { + QJSValue headers; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->deleteResource(req); + + this->connectReply(reply, callback, timeout); +} + +void HttpClient::head(const QString& url, const QJSValue& options, const QJSValue& callback) { + QJSValue headers; + int timeout = -1; + + if (options.isObject()) { + if (options.hasOwnProperty("headers")) { + headers = options.property("headers"); + } + if (options.hasOwnProperty("timeout")) { + auto timeoutProp = options.property("timeout"); + if (timeoutProp.isNumber()) { + timeout = static_cast(timeoutProp.toNumber()); + } + } + } + + auto req = createRequest(url, headers); + auto* reply = this->mManager->head(req); + + this->connectReply(reply, callback, timeout); +} + +} // namespace qs::http diff --git a/src/http/client.hpp b/src/http/client.hpp new file mode 100644 index 00000000..f002f3a5 --- /dev/null +++ b/src/http/client.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::http { + +class HttpClient: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_SINGLETON; + +public: + explicit HttpClient(QObject* parent = nullptr); + + void connectReply(QNetworkReply* reply, const QJSValue& callback, int timeout); + + Q_INVOKABLE void request( + const QString& url, + const QString& verb, + const QJSValue& options, + const QJSValue& callback = QJSValue() + ); + Q_INVOKABLE void + get(const QString& url, const QJSValue& options, const QJSValue& callback = QJSValue()); + Q_INVOKABLE void post( + const QString& url, + const QVariant& body, + const QJSValue& options, + const QJSValue& callback = QJSValue() + ); + Q_INVOKABLE void + put(const QString& url, + const QVariant& body, + const QJSValue& options, + const QJSValue& callback = QJSValue()); + // cannot use delete since it's a reserved keyword + Q_INVOKABLE void + del(const QString& url, const QJSValue& options, const QJSValue& callback = QJSValue()); + Q_INVOKABLE void + head(const QString& url, const QJSValue& options, const QJSValue& callback = QJSValue()); + +private: + QNetworkAccessManager* mManager; +}; + +} // namespace qs::http diff --git a/src/http/modules.md b/src/http/modules.md new file mode 100644 index 00000000..1f247e0b --- /dev/null +++ b/src/http/modules.md @@ -0,0 +1,8 @@ +name = "Quickshell.Http" +description = "HTTP fetch API" +headers = [ + "client.hpp", + "response.hpp", +] +----- +Quickshell's HTTP module. diff --git a/src/http/response.cpp b/src/http/response.cpp new file mode 100644 index 00000000..263a7924 --- /dev/null +++ b/src/http/response.cpp @@ -0,0 +1,61 @@ +#include "response.hpp" + +#include +#include // NOLINT(misc-include-cleaner) +#include +#include +#include +#include +#include +#include + +namespace qs::http { + +HttpResponse::HttpResponse(QNetworkReply* reply, QObject* parent): QObject(parent) { + this->mStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + this->mStatusText = reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + this->mUrl = reply->url(); + this->mData = reply->readAll(); + + auto replyErr = reply->error(); + this->mTimeouted = + replyErr == QNetworkReply::TimeoutError || replyErr == QNetworkReply::OperationCanceledError; + + auto headersList = reply->rawHeaderList(); + for (auto& header: headersList) { + this->mHeadersMap.insert( + QString::fromUtf8(header), + QString::fromUtf8(reply->rawHeader(header)) + ); + } +} + +QUrl HttpResponse::url() { return this->mUrl; } + +QString HttpResponse::text() { return QString::fromUtf8(this->mData); } + +QJsonValue HttpResponse::json() { + QJsonParseError error; // NOLINT(misc-include-cleaner) + auto json = QJsonDocument::fromJson(this->mData, &error); + + if (error.error != QJsonParseError::NoError) { + qmlWarning(this) << "Failed to deserialize json: " << error.errorString(); + return QJsonValue::Undefined; + } + + if (json.isArray()) { + return json.array(); + } + + return json.object(); +} + +QByteArray HttpResponse::arrayBuffer() { return this->mData; } + +QVariantMap HttpResponse::headers() { return this->mHeadersMap; } + +QString HttpResponse::header(const QString& name) { + return this->mHeadersMap.value(name).toString(); +} + +} // namespace qs::http diff --git a/src/http/response.hpp b/src/http/response.hpp new file mode 100644 index 00000000..e6b99dff --- /dev/null +++ b/src/http/response.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::http { + +class HttpResponse: public QObject { + Q_OBJECT; + QML_UNCREATABLE("HttpResponse can only be created by HttpClient"); + Q_PROPERTY(int status READ status CONSTANT); + Q_PROPERTY(QString statusText READ statusText CONSTANT); + Q_PROPERTY(bool timeouted READ timeouted CONSTANT); + +public: + explicit HttpResponse(QNetworkReply* reply, QObject* parent = nullptr); + + Q_INVOKABLE [[nodiscard]] bool success() const { + return this->mStatus >= 200 && this->mStatus < 300; + } + Q_INVOKABLE [[nodiscard]] QUrl url(); + Q_INVOKABLE [[nodiscard]] QString text(); + Q_INVOKABLE [[nodiscard]] QJsonValue json(); + Q_INVOKABLE [[nodiscard]] QByteArray arrayBuffer(); + Q_INVOKABLE [[nodiscard]] QVariantMap headers(); + Q_INVOKABLE [[nodiscard]] QString header(const QString& name); + + [[nodiscard]] int status() const { return this->mStatus; } + + [[nodiscard]] QString statusText() const { return this->mStatusText; } + + [[nodiscard]] bool timeouted() const { return this->mTimeouted; } + +private: + int mStatus; + QString mStatusText; + bool mTimeouted; + QUrl mUrl; + QByteArray mData; + QVariantMap mHeadersMap; +}; + +} // namespace qs::http diff --git a/src/http/test/manual/test.qml b/src/http/test/manual/test.qml new file mode 100644 index 00000000..936e79e7 --- /dev/null +++ b/src/http/test/manual/test.qml @@ -0,0 +1,100 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Http + +FloatingWindow { + minimumSize: "1000x1000" + ColumnLayout { + anchors { + fill: parent + margins: 20 + } + spacing: 20 + + ColumnLayout { + spacing: 5 + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + Label { text: "URL" } + TextField { + id: urlField + Layout.fillWidth: true + selectByMouse: true + text: "https://example.com" + } + Label { text: "Timeout"} + TextField { + id: timeoutField + Layout.fillWidth: true + text: "1000" + validator: IntValidator { bottom: 0 } + inputMethodHints: Qt.ImhDigitsOnly + } + Label { text: "Method" } + ComboBox { + id: methodCombo + Layout.fillWidth: true + model: ["GET", "POST", "PUT", "DELETE", "HEAD"] + } + Label { text: "Response text" } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + + TextArea { + id: respArea + readOnly: true + wrapMode: TextEdit.Wrap + } + } + + RowLayout { + id: buttonRow + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.alignment: Qt.AlignRight + spacing: 10 + + function sendRequest(wTimeout: bool) { + respArea.text = "Sending request..."; + let opts = { + headers: { + "User-Agent": "Quickshell" + } + }; + if (wTimeout) { + opts.timeout = Number(timeoutField.text) + } + + HttpClient.request(urlField.text, methodCombo.currentText, opts, resp => { + if (resp.success()) { + setRespText(resp.text()); + } else { + setRespText(resp.timeouted ? "Timeout error" : `Error: ${reps.statusText}`); + } + }); + } + + // workaround for the TextArea id + function setRespText(text: string) { + respArea.text = text; + } + + Button { + text: "Fetch with timeout" + onClicked: { + parent.sendRequest(true); + } + } + Button { + text: "Fetch" + onClicked: { + parent.sendRequest(false); + } + } + } + } + } +}