diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 52db00a5..87063f92 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -33,3 +33,5 @@ add_subdirectory(services) if (BLUETOOTH) add_subdirectory(bluetooth) endif() + +add_subdirectory(http) diff --git a/src/http/CMakeLists.txt b/src/http/CMakeLists.txt new file mode 100644 index 00000000..99538d5e --- /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-http) diff --git a/src/http/client.cpp b/src/http/client.cpp new file mode 100644 index 00000000..3e51fcb1 --- /dev/null +++ b/src/http/client.cpp @@ -0,0 +1,218 @@ +#include "client.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "response.hpp" + +namespace qs::http { + +HttpClient::HttpClient(QObject* parent) + : QObject(parent) + , mManager(new QNetworkAccessManager(this)) {} + +QNetworkRequest HttpClient::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; +} + +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(); + } + + QJSEngine* engine = qjsEngine(this); + + if (!engine) { + return; + } + if (callback.isCallable()) { + connect(reply, &QNetworkReply::finished, this, [reply, engine, callback]() mutable { + reply->deleteLater(); + 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 = this->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 = this->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 = this->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 = this->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 = this->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 = this->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..65b98a00 --- /dev/null +++ b/src/http/client.hpp @@ -0,0 +1,54 @@ +#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); + + QNetworkRequest createRequest(const QString& url, const QJSValue& headers); + 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..3cdc26d7 --- /dev/null +++ b/src/http/response.cpp @@ -0,0 +1,58 @@ +#include "response.hpp" + +#include +#include +#include +#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::HttpStatusCodeAttribute).toString(); + this->mUrl = reply->url(); + this->mData = reply->readAll(); + + 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; + 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..491835d5 --- /dev/null +++ b/src/http/response.hpp @@ -0,0 +1,46 @@ +#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); + +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; } + +private: + int mStatus; + QString mStatusText; + QUrl mUrl; + QByteArray mData; + QVariantMap mHeadersMap; +}; + +} // namespace qs::http