From 24205b87b92081c0393aef43c78df7827e3c0c2b Mon Sep 17 00:00:00 2001 From: vilas Date: Tue, 4 Aug 2026 19:09:14 +0800 Subject: [PATCH 01/12] feat(http): add Postman-like auth, body types, query params and response extras - Add AuthConfig with Basic/Bearer/ApiKey(in Header or Query) support - Extend RequestContext with BodyType(raw/json/xml/form-urlencoded/binary/form-data), binaryBody and queryParams; fluent Builder methods for each - Enhance ResponseResult with cookies, json() and contentType() - Apply auth/body/query params in NetworkRequest pipeline - GUI: auth settings dialog (incl. ApiKey), binary body page, response Cookies tab, route UI through RequestContextBuilder - Tests: AuthTests/BodyTypeTests suites + GUI buildRequestContext/auth/cookies cases - Fix greedy hex-escape bugs in test fixtures ('\x00DEF' -> C7744, '\x00data' -> 0x00DA) --- include/authconfig.h | 151 ++++++++ include/networkerror.h | 4 + include/requestcontext.h | 101 ++++++ include/responseresult.h | 19 + .../networkrequesttool/NetworkRequestTool.ui | 79 +++++ .../networkrequesttool/networkrequesttool.cpp | 253 +++++++++++--- .../networkrequesttool/networkrequesttool.h | 15 +- source/CMakeLists.txt | 1 + source/networkcommonrequest.cpp | 23 +- source/networkrequest.cpp | 114 +++++- source/networkrequest.h | 11 + test/CMakeLists.txt | 33 ++ test/main_auth.cpp | 9 + test/main_requestbuilder.cpp | 11 +- test/test_auth.cpp | 329 ++++++++++++++++++ test/test_auth.h | 45 +++ test/test_bodytype.cpp | 293 ++++++++++++++++ test/test_bodytype.h | 42 +++ test/test_qtrequester.cpp | 99 ++++++ test/test_qtrequester.h | 4 + test/test_requestbuilder.cpp | 57 ++- test/test_requestbuilder.h | 3 + 22 files changed, 1633 insertions(+), 63 deletions(-) create mode 100644 include/authconfig.h create mode 100644 test/main_auth.cpp create mode 100644 test/test_auth.cpp create mode 100644 test/test_auth.h create mode 100644 test/test_bodytype.cpp create mode 100644 test/test_bodytype.h diff --git a/include/authconfig.h b/include/authconfig.h new file mode 100644 index 0000000..50966df --- /dev/null +++ b/include/authconfig.h @@ -0,0 +1,151 @@ +/* +@Brief: Qt multi-threaded network request module – authentication configuration + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include +#include + +#pragma pack(push, _CRT_PACKING) + +namespace QtNetworkRequest +{ + /// Supported authentication types (extensible for future Digest, OAuth2, etc.) + enum class AuthType : int8_t + { + None = 0, // No authentication + Basic = 1, // HTTP Basic Authentication (RFC 7617) + Bearer = 2, // Bearer Token Authentication (RFC 6750) + ApiKey = 3, // Custom API Key in Header or Query Param + }; + + /// Placement strategy for API Key authentication + enum class ApiKeyPlacement : int8_t + { + Header = 0, // Add as custom request header + QueryParam = 1, // Append to URL query string + }; + + /// Authentication configuration (value type, read-only after RequestContext build) + struct AuthConfig + { + AuthType type{ AuthType::None }; + + // --- Basic Auth --- + QString username; + QString password; + + // --- Bearer Token --- + /// Raw token value. The library auto-prepends "Bearer " prefix when building the header. + QString token; + + // --- API Key --- + QString apiKey; // Header name or query param key + QString apiValue; // Header value or query param value + ApiKeyPlacement apiKeyPlacement{ ApiKeyPlacement::Header }; + + // --- Factory methods --- + + /// Create a Basic Authentication config. + /// @param user Username (must not be empty) + /// @param pass Password + static AuthConfig basic(const QString &user, const QString &pass) + { + AuthConfig c; + c.type = AuthType::Basic; + c.username = user; + c.password = pass; + return c; + } + + /// Create a Bearer Token config. + /// @param tok Raw token string (must not be empty) + static AuthConfig bearer(const QString &tok) + { + AuthConfig c; + c.type = AuthType::Bearer; + c.token = tok; + return c; + } + + /// Create an API Key config. + /// @param key Header name or query param key (must not be empty) + /// @param value Header value or query param value (must not be empty) + /// @param placement Where to inject the key (default: Header) + static AuthConfig apiKeyAuth(const QString &key, const QString &value, + ApiKeyPlacement placement = ApiKeyPlacement::Header) + { + AuthConfig c; + c.type = AuthType::ApiKey; + c.apiKey = key; + c.apiValue = value; + c.apiKeyPlacement = placement; + return c; + } + + /// Validate that required credentials are present for the selected auth type. + /// AuthType::None is always considered valid. + bool isValid() const + { + switch (type) + { + case AuthType::Basic: return !username.isEmpty(); + case AuthType::Bearer: return !token.isEmpty(); + case AuthType::ApiKey: return !apiKey.isEmpty() && !apiValue.isEmpty(); + case AuthType::None: return true; + default: return false; + } + } + + /// Generate the Authorization header value. + /// For Basic: base64-encoded "user:pass" + /// For Bearer: "Bearer " + /// For others: empty QByteArray + QByteArray authorizationHeaderValue() const + { + switch (type) + { + case AuthType::Basic: + { + QByteArray credentials = (username + u':' + password).toUtf8(); + return QByteArrayLiteral("Basic ") + credentials.toBase64(); + } + case AuthType::Bearer: + return QByteArrayLiteral("Bearer ") + token.toUtf8(); + default: + return {}; + } + } + + /// Header name to use for API Key auth (Header placement). + QByteArray apiKeyHeaderName() const { return apiKey.toUtf8(); } + + /// Header value to use for API Key auth (Header placement). + QByteArray apiKeyHeaderValue() const { return apiValue.toUtf8(); } + }; +} + +#pragma pack(pop) diff --git a/include/networkerror.h b/include/networkerror.h index 7c2f7f1..4fa7885 100644 --- a/include/networkerror.h +++ b/include/networkerror.h @@ -83,6 +83,10 @@ namespace QtNetworkRequest InvalidReply, ContentLengthMissing, + // 认证 + AuthInvalid, // Invalid or missing authentication credentials + AuthTypeUnsupported, // Authentication type recognized but not yet implemented + // 文件 / IO FileOpenFailed, FileWriteFailed, diff --git a/include/requestcontext.h b/include/requestcontext.h index 7cbc458..991d5de 100644 --- a/include/requestcontext.h +++ b/include/requestcontext.h @@ -36,6 +36,8 @@ SOFTWARE. #include "taskdata.h" #include "proxyconfig.h" +#include "authconfig.h" +#include #ifndef QT_NO_SSL #include "sslconfig.h" #endif @@ -47,6 +49,19 @@ namespace QtNetworkRequest struct DownloadConfig; struct UploadConfig; + /// Explicit body content type for automatic Content-Type header detection. + /// When BodyType::None (default), behavior is unchanged from prior versions. + enum class BodyType : int8_t + { + None = 0, // No body / backward-compatible (default) + Raw = 1, // Plain text, no automatic Content-Type + Json = 2, // application/json + Xml = 3, // application/xml + FormUrlEncoded = 4, // application/x-www-form-urlencoded + Binary = 5, // application/octet-stream (uses binaryBody field) + FormData = 6, // multipart/form-data (handled by uploadConfig) + }; + // 请求上下文 (Input) struct RequestContext { @@ -62,6 +77,17 @@ namespace QtNetworkRequest QString body; QList cookies; + // Authentication configuration + AuthConfig authConfig; + + // Body type awareness (for automatic Content-Type detection) + BodyType bodyType{ BodyType::None }; + // Binary body data (used when bodyType == Binary) + QByteArray binaryBody; + + // Query parameters (auto-appended to URL) + QMap queryParams; + TaskData task; // 行为配置 @@ -162,6 +188,46 @@ namespace QtNetworkRequest return *this; } + // --- body type --- + RequestContextBuilder &bodyType(BodyType v) + { + m_context->bodyType = v; + return *this; + } + RequestContextBuilder &bodyJson(const QString &json) + { + m_context->body = json; + m_context->bodyType = BodyType::Json; + return *this; + } + RequestContextBuilder &bodyXml(const QString &xml) + { + m_context->body = xml; + m_context->bodyType = BodyType::Xml; + return *this; + } + RequestContextBuilder &bodyFormUrlEncoded(const QMap ¶ms) + { + QUrlQuery q; + for (auto it = params.cbegin(); it != params.cend(); ++it) + q.addQueryItem(it.key(), it.value()); + m_context->body = q.toString(QUrl::FullyEncoded); + m_context->bodyType = BodyType::FormUrlEncoded; + return *this; + } + RequestContextBuilder &bodyRaw(const QString &text) + { + m_context->body = text; + m_context->bodyType = BodyType::Raw; + return *this; + } + RequestContextBuilder &bodyBinary(const QByteArray &data) + { + m_context->binaryBody = data; + m_context->bodyType = BodyType::Binary; + return *this; + } + // --- cookies --- RequestContextBuilder &cookie(const QNetworkCookie &v) { @@ -169,6 +235,41 @@ namespace QtNetworkRequest return *this; } + // --- authentication --- + RequestContextBuilder &authConfig(const AuthConfig &v) + { + m_context->authConfig = v; + return *this; + } + RequestContextBuilder &authBasic(const QString &user, const QString &pass) + { + m_context->authConfig = AuthConfig::basic(user, pass); + return *this; + } + RequestContextBuilder &authBearer(const QString &token) + { + m_context->authConfig = AuthConfig::bearer(token); + return *this; + } + RequestContextBuilder &authApiKey(const QString &key, const QString &value, + ApiKeyPlacement placement = ApiKeyPlacement::Header) + { + m_context->authConfig = AuthConfig::apiKeyAuth(key, value, placement); + return *this; + } + + // --- query parameters --- + RequestContextBuilder &queryParam(const QString &key, const QString &value) + { + m_context->queryParams.insert(key, value); + return *this; + } + RequestContextBuilder &queryParams(const QMap &v) + { + m_context->queryParams = v; + return *this; + } + // --- task --- RequestContextBuilder &taskId(quint64 v) { diff --git a/include/responseresult.h b/include/responseresult.h index e20aab0..60eb99e 100644 --- a/include/responseresult.h +++ b/include/responseresult.h @@ -30,6 +30,8 @@ SOFTWARE. #include #include #include +#include +#include #include "taskdata.h" #include "networkerror.h" @@ -46,6 +48,9 @@ namespace QtNetworkRequest QByteArray body; QMap headers; + // Parsed cookies extracted from Set-Cookie response headers + QList cookies; + TaskData task; // 用户自定义上下文 @@ -63,6 +68,20 @@ namespace QtNetworkRequest bool isSuccess() const { return !error.isError(); } bool isCancelled() const { return error.category == ErrorCategory::Cancelled; } bool isTimeout() const { return error.category == ErrorCategory::Timeout; } + + /// Parse response body as JSON document. + /// Returns a default-constructed (null) QJsonDocument on parse failure. + QJsonDocument json() const { return QJsonDocument::fromJson(body); } + + /// Convenience accessor for the Content-Type response header. + QByteArray contentType() const + { + auto it = headers.find("Content-Type"); + if (it != headers.end()) return it.value(); + it = headers.find("content-type"); + if (it != headers.end()) return it.value(); + return {}; + } }; } diff --git a/samples/networkrequesttool/NetworkRequestTool.ui b/samples/networkrequesttool/NetworkRequestTool.ui index 930f915..3881f63 100644 --- a/samples/networkrequesttool/NetworkRequestTool.ui +++ b/samples/networkrequesttool/NetworkRequestTool.ui @@ -1629,6 +1629,56 @@ QComboBox#cmb_method { + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + No file selected + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Browse... + + + + + + + @@ -1696,6 +1746,35 @@ QComboBox#cmb_method { + + + Cookies + + + + 8 + + + 8 + + + 8 + + + 8 + + + 8 + + + + + true + + + + + diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index 1e1a33f..55c8395 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -188,6 +188,14 @@ void NetworkRequestTool::initializeConnections() // Form data connect(ui.btn_add_body, &QPushButton::clicked, this, &NetworkRequestTool::onAddBodyParam); connect(ui.btn_remove_body, &QPushButton::clicked, this, &NetworkRequestTool::onRemoveBodyParam); + connect(ui.btn_binary_browse, &QPushButton::clicked, this, [=]() { + QString path = QFileDialog::getOpenFileName(this, "Select binary file"); + if (!path.isEmpty()) + { + m_binaryFilePath = path; + ui.lineEdit_binary_path->setText(path); + } + }); connect(ui.cmb_body_type, &QComboBox::currentTextChanged, this, &NetworkRequestTool::onBodyTypeComboChanged); connect(ui.table_body, &QTableWidget::cellChanged, this, &NetworkRequestTool::onBodyParamTypeChanged); @@ -361,6 +369,11 @@ void NetworkRequestTool::onBodyTypeChanged(const QString &bodyType) connect(ui.textEdit_body, &QTextEdit::textChanged, this, &NetworkRequestTool::onBodyTextChanged, Qt::UniqueConnection); } } + else if (bodyType == "binary") + { + ui.stackedWidget_body->setCurrentWidget(ui.page_binary); + ui.textEdit_body->setEnabled(false); + } else { ui.stackedWidget_body->setCurrentWidget(ui.page_form); @@ -484,47 +497,16 @@ void NetworkRequestTool::updateContentTypeHeader() void NetworkRequestTool::onSendRequest() { - QString url = buildUrlWithParams(); - if (url.isEmpty()) + QString raw = ui.lineEdit_url->text().trimmed(); + if (raw.isEmpty() || !QUrl(raw).isValid()) { QMessageBox::warning(this, "Error", "Please enter a valid URL"); return; } - applyAuthHeader(); + applyAuthHeader(); // preview header write-back (Approach A) - std::unique_ptr req = std::make_unique(); - req->url = url; - req->type = getRequestType(); - req->headers = getHeaders(); - req->body = getRequestBody(); - req->behavior.maxRedirectionCount = 3; - applyRequestSettings(req); - if (req->type == RequestType::Download || req->type == RequestType::MTDownload) - { - // 示例代码,此工具无这两种type - req->downloadConfig = std::make_unique(); - req->downloadConfig->saveFileName = ""; - req->downloadConfig->saveDir = ""; - req->downloadConfig->overwriteFile = true; - req->downloadConfig->threadCount = 32; - req->behavior.showProgress = true; - } - else if (req->type == RequestType::Upload) - { - // 示例代码,此工具无这这种type - req->uploadConfig = std::make_unique(); - req->uploadConfig->filePath = "your file path"; - req->uploadConfig->usePutMethod = true; - req->behavior.showProgress = true; - } - if (req->type == RequestType::Post && currentBodyType == "form-data") - { - req->uploadConfig = std::make_unique(); - req->uploadConfig->useFormData = true; - req->uploadConfig->files = files; - req->uploadConfig->kvPairs = kvPairs; - } + std::unique_ptr req = buildRequestContext(); // Create network request std::shared_ptr pReply = NetworkRequestManager::globalInstance()->postRequest(std::move(req)); @@ -535,7 +517,7 @@ void NetworkRequestTool::onSendRequest() clearResponse(); appendToResponseBody("Sending request...\n", QColor(0, 120, 212)); - appendToResponseBody("URL: " + url + "\n", QColor(204, 204, 204)); + appendToResponseBody("URL: " + raw + "\n", QColor(204, 204, 204)); appendToResponseBody("Method: " + currentMethod + "\n\n", QColor(204, 204, 204)); } } @@ -651,23 +633,104 @@ QString NetworkRequestTool::getRequestBody() else if (currentBodyType == "x-www-form-urlencoded") { QUrlQuery query; - // Iterate through form data table - for (int i = 0; i < ui.table_body->rowCount(); ++i) - { - QTableWidgetItem *keyItem = ui.table_body->item(i, 0); - QTableWidgetItem *valueItem = ui.table_body->item(i, 1); - - if (keyItem && valueItem && !keyItem->text().isEmpty()) - { - query.addQueryItem(keyItem->text(), valueItem->text()); - } - } + auto params = getFormUrlEncodedMap(); + for (auto it = params.cbegin(); it != params.cend(); ++it) + query.addQueryItem(it.key(), it.value()); return query.toString(); } return QString(); } +QString NetworkRequestTool::baseUrlFromInput() const +{ + QString raw = ui.lineEdit_url->text().trimmed(); + QUrl url(raw); + if (!url.isValid()) + return raw; + return url.adjusted(QUrl::RemoveQuery).toString(); +} + +QMap NetworkRequestTool::getQueryParams() const +{ + QMap params; + int rows = ui.table_params->rowCount(); + for (int i = 0; i < rows; ++i) + { + QTableWidgetItem *keyItem = ui.table_params->item(i, 0); + QTableWidgetItem *valueItem = ui.table_params->item(i, 1); + if (keyItem && valueItem && !keyItem->text().isEmpty()) + params.insert(keyItem->text(), valueItem->text()); + } + return params; +} + +QMap NetworkRequestTool::getFormUrlEncodedMap() const +{ + QMap params; + int rows = ui.table_body->rowCount(); + for (int i = 0; i < rows; ++i) + { + QTableWidgetItem *keyItem = ui.table_body->item(i, 0); + QTableWidgetItem *valueItem = ui.table_body->item(i, 1); + if (keyItem && valueItem && !keyItem->text().isEmpty()) + params.insert(keyItem->text(), valueItem->text()); + } + return params; +} + +QByteArray NetworkRequestTool::readBinaryFile(const QString &path) const +{ + if (path.isEmpty()) + return QByteArray(); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return QByteArray(); + return file.readAll(); +} + +std::unique_ptr NetworkRequestTool::buildRequestContext() +{ + RequestContextBuilder builder; + builder.url(baseUrlFromInput()) + .type(getRequestType()) + .headers(getHeaders()) + .queryParams(getQueryParams()) + .authConfig(buildAuthConfig()); + + // Body type -> builder method mapping + if (currentBodyType == "raw") + { + const QString text = ui.textEdit_body->toPlainText(); + if (currentRawType == "JSON") + builder.bodyJson(text); + else if (currentRawType == "XML") + builder.bodyXml(text); + else + builder.bodyRaw(text); + } + else if (currentBodyType == "x-www-form-urlencoded") + { + builder.bodyFormUrlEncoded(getFormUrlEncodedMap()); + } + else if (currentBodyType == "binary") + { + builder.bodyBinary(readBinaryFile(m_binaryFilePath)); + } + // form-data is handled via UploadConfig below; "none" sets no body. + + auto req = builder.build(); + applyRequestSettings(req); + if (req->type == RequestType::Post && currentBodyType == "form-data") + { + req->uploadConfig = std::make_unique(); + req->uploadConfig->useFormData = true; + req->uploadConfig->files = files; + req->uploadConfig->kvPairs = kvPairs; + } + return req; +} + void NetworkRequestTool::onResponse(QSharedPointer rsp) { // Safety check to prevent crashes during object destruction @@ -681,12 +744,16 @@ void NetworkRequestTool::onResponse(QSharedPointerheaders); m_highlighter.reset(); - if (isJsonResponse(rsp->headers)) + QByteArray contentType = rsp->contentType().toLower(); + if (contentType.contains("application/json")) { m_highlighter = std::make_unique(ui.textEdit_response_body->document()); - displayJsonResponse(rsp->body); + QJsonDocument doc = rsp->json(); + QString pretty = doc.isNull() ? rsp->body + : QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); + displayJsonResponse(pretty); } - else if (isXmlResponse(rsp->headers)) + else if (contentType.contains("xml")) { m_highlighter = std::make_unique(ui.textEdit_response_body->document()); appendToResponseBody(rsp->body, QColor(16, 124, 16)); @@ -695,6 +762,8 @@ void NetworkRequestTool::onResponse(QSharedPointerbody, QColor(16, 124, 16)); } + + displayResponseCookies(rsp->cookies); } else { @@ -710,6 +779,30 @@ void NetworkRequestTool::onResponse(QSharedPointersetText(info); } +void NetworkRequestTool::displayResponseCookies(const QList &cookies) +{ + if (!ui.textEdit_response_cookies) + return; + if (cookies.isEmpty()) + { + ui.textEdit_response_cookies->setPlainText("(no cookies)"); + return; + } + QStringList lines; + for (const QNetworkCookie &c : cookies) + { + QString line = QString("%1 = %2").arg(QString::fromUtf8(c.name()), QString::fromUtf8(c.value())); + if (!c.domain().isEmpty()) + line += QString("; Domain=%1").arg(c.domain()); + if (!c.path().isEmpty()) + line += QString("; Path=%1").arg(c.path()); + if (!c.expirationDate().isNull()) + line += QString("; Expires=%1").arg(c.expirationDate().toString(Qt::ISODate)); + lines.append(line); + } + ui.textEdit_response_cookies->setPlainText(lines.join("\n")); +} + bool NetworkRequestTool::isJsonResponse(const QMap &headers) { QByteArray contentType = headers.value("Content-Type").toLower(); @@ -780,6 +873,15 @@ void NetworkRequestTool::clearResponse() { clearResponseBody(); clearResponseHeaders(); + clearResponseCookies(); +} + +void NetworkRequestTool::clearResponseCookies() +{ + if (ui.textEdit_response_cookies) + { + ui.textEdit_response_cookies->clear(); + } } void NetworkRequestTool::clearResponseBody() @@ -1132,6 +1234,10 @@ void NetworkRequestTool::onBodyTypeComboChanged(const QString &type) ui.cmb_raw_type->setEnabled(false); ui.textEdit_body->clear(); } + else if (type == "binary") + { + ui.stackedWidget_body->setCurrentWidget(ui.page_binary); + } else { ui.stackedWidget_body->setCurrentWidget(ui.page_form); @@ -1284,7 +1390,7 @@ void NetworkRequestTool::onSettingsClicked() authLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); auto *authTypeCombo = new QComboBox(); - authTypeCombo->addItems({"None", "Basic", "Bearer"}); + authTypeCombo->addItems({"None", "Basic", "Bearer", "ApiKey"}); authTypeCombo->setCurrentText(m_settings.authType); auto *authUserEdit = new QLineEdit(m_settings.authUsername); authUserEdit->setPlaceholderText("Username"); @@ -1293,22 +1399,39 @@ void NetworkRequestTool::onSettingsClicked() authPassEdit->setEchoMode(QLineEdit::Password); auto *authTokenEdit = new QLineEdit(m_settings.authToken); authTokenEdit->setPlaceholderText("Token"); + auto *authApiKeyEdit = new QLineEdit(m_settings.authApiKey); + authApiKeyEdit->setPlaceholderText("Header / Query key"); + auto *authApiValueEdit = new QLineEdit(m_settings.authApiValue); + authApiValueEdit->setPlaceholderText("API key value"); + auto *authApiLocCombo = new QComboBox(); + authApiLocCombo->addItems({"Header", "Query"}); + authApiLocCombo->setCurrentText(m_settings.authApiLocation.isEmpty() ? "Header" : m_settings.authApiLocation); authLayout->addRow("Type:", authTypeCombo); authLayout->addRow("User:", authUserEdit); authLayout->addRow("Password:", authPassEdit); authLayout->addRow("Token:", authTokenEdit); + authLayout->addRow("API Key:", authApiKeyEdit); + authLayout->addRow("API Value:", authApiValueEdit); + authLayout->addRow("API Location:", authApiLocCombo); auto onAuthTypeChanged = [=](const QString &type) { bool isBasic = (type == "Basic"); bool isBearer = (type == "Bearer"); + bool isApiKey = (type == "ApiKey"); authUserEdit->setVisible(isBasic); authPassEdit->setVisible(isBasic); authTokenEdit->setVisible(isBearer); + authApiKeyEdit->setVisible(isApiKey); + authApiValueEdit->setVisible(isApiKey); + authApiLocCombo->setVisible(isApiKey); // Hide the label row when the field is hidden so the layout collapses authLayout->labelForField(authUserEdit)->setVisible(isBasic); authLayout->labelForField(authPassEdit)->setVisible(isBasic); authLayout->labelForField(authTokenEdit)->setVisible(isBearer); + authLayout->labelForField(authApiKeyEdit)->setVisible(isApiKey); + authLayout->labelForField(authApiValueEdit)->setVisible(isApiKey); + authLayout->labelForField(authApiLocCombo)->setVisible(isApiKey); }; connect(authTypeCombo, &QComboBox::currentTextChanged, onAuthTypeChanged); onAuthTypeChanged(authTypeCombo->currentText()); @@ -1402,6 +1525,9 @@ void NetworkRequestTool::onSettingsClicked() m_settings.authUsername = authUserEdit->text(); m_settings.authPassword = authPassEdit->text(); m_settings.authToken = authTokenEdit->text(); + m_settings.authApiKey = authApiKeyEdit->text(); + m_settings.authApiValue = authApiValueEdit->text(); + m_settings.authApiLocation = authApiLocCombo->currentText(); m_settings.proxyEnabled = proxyCheck->isChecked(); m_settings.proxyHost = proxyHostEdit->text(); m_settings.proxyPort = static_cast(proxyPortSpin->value()); @@ -1426,10 +1552,35 @@ void NetworkRequestTool::applyAuthHeader() { updateHeader("Authorization", "Bearer " + m_settings.authToken); } + else if (m_settings.authType == "ApiKey") + { + // Header placement: write a preview header (the library will skip it as + // it is already present). Query placement: do NOT write to headers, the + // library appends it to the URL query instead. + if (m_settings.authApiLocation != "Query" && !m_settings.authApiKey.isEmpty()) + updateHeader(m_settings.authApiKey, m_settings.authApiValue); + } +} + +AuthConfig NetworkRequestTool::buildAuthConfig() const +{ + if (m_settings.authType == "Basic" && !m_settings.authUsername.isEmpty()) + return AuthConfig::basic(m_settings.authUsername, m_settings.authPassword); + if (m_settings.authType == "Bearer" && !m_settings.authToken.isEmpty()) + return AuthConfig::bearer(m_settings.authToken); + if (m_settings.authType == "ApiKey" && !m_settings.authApiKey.isEmpty()) + { + ApiKeyPlacement loc = (m_settings.authApiLocation == "Query") + ? ApiKeyPlacement::QueryParam + : ApiKeyPlacement::Header; + return AuthConfig::apiKeyAuth(m_settings.authApiKey, m_settings.authApiValue, loc); + } + return AuthConfig(); // None } void NetworkRequestTool::applyRequestSettings(std::unique_ptr &req) { + req->behavior.maxRedirectionCount = 3; req->behavior.transferTimeout = m_settings.transferTimeoutMs; req->behavior.retryOnFailed = m_settings.retryEnabled; req->behavior.maxRetryCount = m_settings.maxRetryCount; diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index d3730bb..94d72b4 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -4,6 +4,7 @@ #include #include "ui_NetworkRequestTool.h" #include "requestcontext.h" +#include "authconfig.h" #include "responseresult.h" #include #include @@ -46,10 +47,13 @@ struct RequestSettings int retryDelayMs = 1000; // Auth - QString authType; // "None", "Basic", "Bearer" + QString authType; // "None", "Basic", "Bearer", "ApiKey" QString authUsername; QString authPassword; QString authToken; + QString authApiKey; + QString authApiValue; + QString authApiLocation; // "Header" / "Query" }; class NetworkRequestTool : public QMainWindow @@ -94,9 +98,15 @@ private slots: void updateBodyTypeFromContentType(const QString &contentType); void updateHeader(const QString &key, const QString &value); QString buildUrlWithParams(); + QString baseUrlFromInput() const; QMap getHeaders(); QString getRequestBody(); + QMap getQueryParams() const; + QMap getFormUrlEncodedMap() const; + QByteArray readBinaryFile(const QString &path) const; void applyAuthHeader(); + AuthConfig buildAuthConfig() const; + std::unique_ptr buildRequestContext(); void applyRequestSettings(std::unique_ptr &req); RequestType getRequestType(); void clearResponse(); @@ -106,6 +116,8 @@ private slots: void clearResponseBody(); void clearResponseHeaders(); void displayResponseHeaders(const QMap &headers); + void displayResponseCookies(const QList &cookies); + void clearResponseCookies(); bool isJsonResponse(const QMap &headers); bool isXmlResponse(const QMap &headers); bool isOctetStreamResponse(const QMap &headers); @@ -141,6 +153,7 @@ private slots: QListWidgetItem *currentHistoryItem; RequestSettings m_settings; QLabel *m_labelResponseInfo; + QString m_binaryFilePath; }; } // namespace QtNetworkRequest diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 2094081..3b8beaf 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -46,6 +46,7 @@ add_library(QNetworkRequest SHARED ${CMAKE_SOURCE_DIR}/include/sslconfig.h ${CMAKE_SOURCE_DIR}/include/proxyconfig.h ${CMAKE_SOURCE_DIR}/include/requestcontext.h + ${CMAKE_SOURCE_DIR}/include/authconfig.h ${CMAKE_SOURCE_DIR}/include/responseresult.h ${CMAKE_SOURCE_DIR}/include/networkerror.h ${CMAKE_SOURCE_DIR}/include/networkrequestglobal.h diff --git a/source/networkcommonrequest.cpp b/source/networkcommonrequest.cpp index 2ba58db..6f4ced2 100644 --- a/source/networkcommonrequest.cpp +++ b/source/networkcommonrequest.cpp @@ -59,7 +59,7 @@ void NetworkCommonRequest::start() m_nBytesSent = fi.size(); } else - m_nBytesSent = m_upContext->body.toUtf8().size(); + m_nBytesSent = effectiveRequestBody().size(); } const QUrl &url = m_url; @@ -85,6 +85,16 @@ void NetworkCommonRequest::start() } } + // Validate authentication configuration + if (m_upContext->authConfig.type != AuthType::None && !m_upContext->authConfig.isValid()) + { + setError(ErrorCategory::Configuration, ErrorCode::AuthInvalid, + QString("Authentication error: Invalid credentials for auth type %1") + .arg(static_cast(m_upContext->authConfig.type))); + emit response(ToFailedResult()); + return; + } + QNetworkRequest request = prepareRequest(); // Set default User-Agent if not provided (prepareRequest sets custom headers, @@ -100,7 +110,7 @@ void NetworkCommonRequest::start() } else if (m_upContext->type == RequestType::Patch) { - const QByteArray &bytes = m_upContext->body.toUtf8(); + const QByteArray &bytes = effectiveRequestBody(); m_pNetworkReply = m_pNetworkManager->sendCustomRequest(request, "PATCH", bytes); } else if (m_upContext->type == RequestType::Options) @@ -112,13 +122,14 @@ void NetworkCommonRequest::start() bool bFormData = m_upContext->uploadConfig && m_upContext->uploadConfig->useFormData && !m_upContext->uploadConfig->files.isEmpty(); if (!bFormData) { - if (!request.hasRawHeader("Content-Type")) + // Only default to application/x-www-form-urlencoded when bodyType is None (backward compat) + if (m_upContext->bodyType == BodyType::None && + !request.hasRawHeader("Content-Type") && !request.hasRawHeader("content-type")) { - // Use application/x-www-form-urlencoded by default request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); } - const QByteArray &bytes = m_upContext->body.toUtf8(); + const QByteArray &bytes = effectiveRequestBody(); // Let Qt automatically handle Content-Length, remove manual setting // request.setHeader(QNetworkRequest::ContentLengthHeader, bytes.length()); @@ -192,7 +203,7 @@ void NetworkCommonRequest::start() } else { - const QByteArray &bytes = m_upContext->body.toUtf8(); + const QByteArray &bytes = effectiveRequestBody(); m_pNetworkReply = m_pNetworkManager->put(request, bytes); } } else if (m_upContext->type == RequestType::Delete) diff --git a/source/networkrequest.cpp b/source/networkrequest.cpp index b00ecc9..21c6a0d 100644 --- a/source/networkrequest.cpp +++ b/source/networkrequest.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "networkdownloadrequest.h" #include "networkuploadrequest.h" #include "networkcommonrequest.h" @@ -386,16 +387,43 @@ QNetworkRequest NetworkRequest::prepareRequest() } } - QNetworkRequest request(m_url); + // --- Build URL: append queryParams and API Key (QueryParam placement) --- + QUrl url = m_url; + bool bHasQueryParams = !m_upContext->queryParams.isEmpty(); + bool bHasApiKeyQuery = (m_upContext->authConfig.type == AuthType::ApiKey + && m_upContext->authConfig.apiKeyPlacement == ApiKeyPlacement::QueryParam); + if (bHasQueryParams || bHasApiKeyQuery) + { + QUrlQuery query(url); + if (bHasQueryParams) + { + for (auto it = m_upContext->queryParams.cbegin(); it != m_upContext->queryParams.cend(); ++it) + query.addQueryItem(it.key(), it.value()); + } + if (bHasApiKeyQuery) + { + const AuthConfig &auth = m_upContext->authConfig; + query.addQueryItem(auth.apiKey, auth.apiValue); + } + url.setQuery(query); + } + + QNetworkRequest request(url); QtCompat::setTransferTimeout(request, m_upContext->behavior.transferTimeout); - // Set custom headers + // Set custom headers (user-set headers take priority) auto iter = m_upContext->headers.cbegin(); for (; iter != m_upContext->headers.cend(); ++iter) { request.setRawHeader(iter.key(), iter.value()); } + // Apply authentication headers (only if user hasn't explicitly set them) + applyAuthConfig(request); + + // Auto-detect Content-Type based on BodyType (only if user hasn't set it) + applyBodyTypeContentType(request); + #ifndef QT_NO_SSL applySslConfig(request); #endif @@ -466,6 +494,80 @@ bool NetworkRequest::handleFailure() return false; // caller should emit failure } +void NetworkRequest::applyAuthConfig(QNetworkRequest &request) +{ + const AuthConfig &auth = m_upContext->authConfig; + if (auth.type == AuthType::None || !auth.isValid()) + return; + + switch (auth.type) + { + case AuthType::Basic: + case AuthType::Bearer: + { + QByteArray authVal = auth.authorizationHeaderValue(); + // Case-insensitive: don't overwrite user-set Authorization header + if (!request.hasRawHeader("Authorization") && + !request.hasRawHeader("authorization")) + { + request.setRawHeader("Authorization", authVal); + } + break; + } + case AuthType::ApiKey: + { + if (auth.apiKeyPlacement == ApiKeyPlacement::Header) + { + QByteArray keyBytes = auth.apiKeyHeaderName(); + if (!request.hasRawHeader(keyBytes)) + { + request.setRawHeader(keyBytes, auth.apiKeyHeaderValue()); + } + } + // QueryParam placement handled in prepareRequest() URL building + break; + } + default: + break; + } +} + +void NetworkRequest::applyBodyTypeContentType(QNetworkRequest &request) +{ + // Only auto-set Content-Type if user hasn't explicitly set it + if (request.hasRawHeader("Content-Type") || + request.hasRawHeader("content-type")) + return; + + switch (m_upContext->bodyType) + { + case BodyType::Json: + request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json")); + break; + case BodyType::Xml: + request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/xml")); + break; + case BodyType::FormUrlEncoded: + request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/x-www-form-urlencoded")); + break; + case BodyType::Binary: + request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/octet-stream")); + break; + case BodyType::None: + case BodyType::Raw: + case BodyType::FormData: + // No automatic Content-Type for these types + break; + } +} + +QByteArray NetworkRequest::effectiveRequestBody() const +{ + if (m_upContext->bodyType == BodyType::Binary && !m_upContext->binaryBody.isEmpty()) + return m_upContext->binaryBody; + return m_upContext->body.toUtf8(); +} + void NetworkRequest::collectResponse(QMap& outHeaders, QByteArray& outBody) { if (!m_bAbortManual && m_pNetworkReply && m_pNetworkReply->isOpen()) @@ -475,6 +577,14 @@ void NetworkRequest::collectResponse(QMap& outHeaders, Q { outHeaders[header] = m_pNetworkReply->rawHeader(header); } + + // Extract parsed cookies from Set-Cookie response headers + if (m_spResult) + { + QVariant cookieVar = m_pNetworkReply->header(QNetworkRequest::SetCookieHeader); + if (cookieVar.isValid()) + m_spResult->cookies = cookieVar.value>(); + } } } diff --git a/source/networkrequest.h b/source/networkrequest.h index 0ab67e8..9256520 100644 --- a/source/networkrequest.h +++ b/source/networkrequest.h @@ -81,6 +81,17 @@ namespace QtNetworkRequest /// Populate responseHeaders + read body from reply into result. void collectResponse(QMap& outHeaders, QByteArray& outBody); + /// Apply auth-generated headers to the request. + /// User-set headers with the same name take priority (not overwritten). + void applyAuthConfig(QNetworkRequest &request); + + /// Auto-set Content-Type header based on BodyType when not explicitly set by user. + void applyBodyTypeContentType(QNetworkRequest &request); + + /// Returns the effective request body as QByteArray. + /// Uses binaryBody for BodyType::Binary, otherwise body.toUtf8(). + QByteArray effectiveRequestBody() const; + /// Cleanup reply with deleteLater() and null the pointer. void disposeReply(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 44d3bef..9070836 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -48,6 +48,8 @@ set_tests_properties(UnitTests PROPERTIES TIMEOUT 120) add_executable(BuilderTests main_requestbuilder.cpp test_requestbuilder.cpp + test_bodytype.cpp + httptestserver.cpp ) target_link_libraries(BuilderTests @@ -72,6 +74,37 @@ endif() add_test(NAME BuilderTests COMMAND BuilderTests) set_tests_properties(BuilderTests PROPERTIES TIMEOUT 60) +# ══════════════════════════════════════════════════════════════════════════════ +# AuthTests — authentication system integration tests +# ══════════════════════════════════════════════════════════════════════════════ +add_executable(AuthTests + main_auth.cpp + test_auth.cpp + httptestserver.cpp +) + +target_link_libraries(AuthTests + QNetworkRequest + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Test +) + +target_include_directories(AuthTests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include +) + +set_output_directories(AuthTests) + +if(WIN32) + copy_openssl_dlls(AuthTests "${OPENSSL_BIN_DIR}") + copy_library_artifact(AuthTests QNetworkRequest) +endif() + +add_test(NAME AuthTests COMMAND AuthTests) +set_tests_properties(AuthTests PROPERTIES TIMEOUT 120) + # ══════════════════════════════════════════════════════════════════════════════ # UtilityTests — pure NetworkRequestUtility unit tests (filesystem only) # diff --git a/test/main_auth.cpp b/test/main_auth.cpp new file mode 100644 index 0000000..eaa60d4 --- /dev/null +++ b/test/main_auth.cpp @@ -0,0 +1,9 @@ +#include +#include "test_auth.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + AuthTests tc; + return QTest::qExec(&tc, argc, argv); +} diff --git a/test/main_requestbuilder.cpp b/test/main_requestbuilder.cpp index 57e9233..71ba404 100644 --- a/test/main_requestbuilder.cpp +++ b/test/main_requestbuilder.cpp @@ -1,6 +1,8 @@ #include #include #include "test_requestbuilder.h" +#include "test_bodytype.h" +#include "networkrequestmanager.h" // Dedicated entry point for the pure (no-network) RequestContextBuilder unit // tests. Kept in its own executable so the fluent-API contract tests run @@ -8,7 +10,14 @@ int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); + QtNetworkRequest::NetworkRequestManager::initialize(); + int status = 0; TestRequestBuilder testRequestBuilder; - return QTest::qExec(&testRequestBuilder, argc, argv); + status |= QTest::qExec(&testRequestBuilder, argc, argv); + BodyTypeTests bodyTypeTests; + status |= QTest::qExec(&bodyTypeTests, argc, argv); + + QtNetworkRequest::NetworkRequestManager::unInitialize(); + return status; } diff --git a/test/test_auth.cpp b/test/test_auth.cpp new file mode 100644 index 0000000..f0d3a95 --- /dev/null +++ b/test/test_auth.cpp @@ -0,0 +1,329 @@ +#include "test_auth.h" +#include +#include +#include +#include +#include "httptestserver.h" + +using namespace QtNetworkRequest; + +AuthTests::AuthTests() + : QObject(nullptr) +{ +} + +void AuthTests::initTestCase() +{ + NetworkRequestManager::initialize(); + m_manager = NetworkRequestManager::globalInstance(); + QVERIFY(m_manager != nullptr); +} + +void AuthTests::cleanupTestCase() +{ + NetworkRequestManager::unInitialize(); +} + +void AuthTests::waitForResponse(int timeoutMs) +{ + m_responseReceived = false; + m_lastResult.clear(); + QElapsedTimer timer; + timer.start(); + while (!m_responseReceived && timer.elapsed() < timeoutMs) + { + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + } +} + +// ============================================================================ +// No-network builder validation tests +// ============================================================================ + +void AuthTests::testAuthBasicBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Get) + .authBasic("alice", "secret") + .build(); + + QVERIFY(ctx->authConfig.type == AuthType::Basic); + QCOMPARE(ctx->authConfig.username, QString("alice")); + QCOMPARE(ctx->authConfig.password, QString("secret")); +} + +void AuthTests::testAuthBearerBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Get) + .authBearer("tok-abc123") + .build(); + + QVERIFY(ctx->authConfig.type == AuthType::Bearer); + QCOMPARE(ctx->authConfig.token, QString("tok-abc123")); +} + +void AuthTests::testAuthApiKeyHeaderBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Get) + .authApiKey("X-API-Key", "my-secret-key") + .build(); + + QVERIFY(ctx->authConfig.type == AuthType::ApiKey); + QCOMPARE(ctx->authConfig.apiKey, QString("X-API-Key")); + QCOMPARE(ctx->authConfig.apiValue, QString("my-secret-key")); + QVERIFY(ctx->authConfig.apiKeyPlacement == ApiKeyPlacement::Header); +} + +void AuthTests::testAuthApiKeyQueryBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Get) + .authApiKey("apikey", "val123", ApiKeyPlacement::QueryParam) + .build(); + + QVERIFY(ctx->authConfig.type == AuthType::ApiKey); + QVERIFY(ctx->authConfig.apiKeyPlacement == ApiKeyPlacement::QueryParam); +} + +void AuthTests::testAuthConfigIsValid() +{ + // Valid cases + QVERIFY(AuthConfig::basic("user", "pass").isValid()); + QVERIFY(AuthConfig::bearer("token").isValid()); + QVERIFY(AuthConfig::apiKeyAuth("key", "val").isValid()); + + // None is always valid + AuthConfig none; + QVERIFY(none.isValid()); + + // Invalid cases + QVERIFY(!AuthConfig::basic("", "pass").isValid()); // empty username + QVERIFY(!AuthConfig::bearer("").isValid()); // empty token + QVERIFY(!AuthConfig::apiKeyAuth("", "val").isValid()); // empty key + QVERIFY(!AuthConfig::apiKeyAuth("key", "").isValid()); // empty value +} + +void AuthTests::testAuthBasicHeaderValue() +{ + AuthConfig cfg = AuthConfig::basic("user", "pass"); + QByteArray headerVal = cfg.authorizationHeaderValue(); + + QVERIFY(headerVal.startsWith("Basic ")); + // "user:pass" base64 encoded = "dXNlcjpwYXNz" + QCOMPARE(headerVal, QByteArray("Basic dXNlcjpwYXNz")); +} + +void AuthTests::testAuthBearerHeaderValue() +{ + AuthConfig cfg = AuthConfig::bearer("my-token"); + QByteArray headerVal = cfg.authorizationHeaderValue(); + + QCOMPARE(headerVal, QByteArray("Bearer my-token")); +} + +void AuthTests::testAuthNoneNoHeader() +{ + AuthConfig cfg; + QVERIFY(cfg.type == AuthType::None); + QVERIFY(cfg.authorizationHeaderValue().isEmpty()); +} + +// ============================================================================ +// Network integration tests using local HTTP test server +// ============================================================================ + +void AuthTests::testAuthBasicRequest() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/get"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Get) + .authBasic("admin", "test123") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult != nullptr); + QVERIFY(m_lastResult->isSuccess()); + + // Verify the server echoed back the Authorization header + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QString authHeader = headers["authorization"].toString(); + QVERIFY(authHeader.startsWith("Basic ")); +} + +void AuthTests::testAuthBearerRequest() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/get"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Get) + .authBearer("tok-test-456") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["authorization"].toString(), QString("Bearer tok-test-456")); +} + +void AuthTests::testAuthApiKeyHeaderRequest() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/get"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Get) + .authApiKey("X-Custom-Key", "custom-value-789") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["x-custom-key"].toString(), QString("custom-value-789")); +} + +void AuthTests::testAuthApiKeyQueryRequest() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/get"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Get) + .authApiKey("token", "query-token-xyz", ApiKeyPlacement::QueryParam) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + // The test server echoes the URL; verify query param is present + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject args = doc.object()["args"].toObject(); + QCOMPARE(args["token"].toString(), QString("query-token-xyz")); +} + +void AuthTests::testAuthHeaderPriority() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/get"; + + // User explicitly sets Authorization header; authBasic should NOT overwrite it + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Get) + .header("Authorization", "CustomAuthValue") + .authBasic("user", "pass") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["authorization"].toString(), QString("CustomAuthValue")); +} + +void AuthTests::testAuthInvalidCredentials() +{ + // Basic auth with empty username should produce Configuration error + auto ctx = RequestContextBuilder() + .url("https://example.com/api") + .type(RequestType::Get) + .authBasic("", "pass") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(3000); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult != nullptr); + QVERIFY(!m_lastResult->isSuccess()); + QVERIFY(m_lastResult->error.category == ErrorCategory::Configuration); + QVERIFY(m_lastResult->error.code == ErrorCode::AuthInvalid); +} + +// Tests run via main_auth.cpp entry point +#include "test_auth.moc" diff --git a/test/test_auth.h b/test/test_auth.h new file mode 100644 index 0000000..8ad7b52 --- /dev/null +++ b/test/test_auth.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include "requestcontext.h" +#include "networkrequestmanager.h" +#include "networkreply.h" +#include "httptestserver.h" + +class AuthTests : public QObject +{ + Q_OBJECT + +public: + AuthTests(); + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + + // No-network builder validation tests + void testAuthBasicBuilder(); + void testAuthBearerBuilder(); + void testAuthApiKeyHeaderBuilder(); + void testAuthApiKeyQueryBuilder(); + void testAuthConfigIsValid(); + void testAuthBasicHeaderValue(); + void testAuthBearerHeaderValue(); + void testAuthNoneNoHeader(); + + // Network integration tests + void testAuthBasicRequest(); + void testAuthBearerRequest(); + void testAuthApiKeyHeaderRequest(); + void testAuthApiKeyQueryRequest(); + void testAuthHeaderPriority(); + void testAuthInvalidCredentials(); + +private: + void waitForResponse(int timeoutMs = 5000); + + QtNetworkRequest::NetworkRequestManager *m_manager{ nullptr }; + QSharedPointer m_lastResult; + bool m_responseReceived{ false }; +}; diff --git a/test/test_bodytype.cpp b/test/test_bodytype.cpp new file mode 100644 index 0000000..d097800 --- /dev/null +++ b/test/test_bodytype.cpp @@ -0,0 +1,293 @@ +#include "test_bodytype.h" +#include +#include +#include +#include "httptestserver.h" + +using namespace QtNetworkRequest; + +BodyTypeTests::BodyTypeTests() + : QObject(nullptr) +{ +} + +void BodyTypeTests::initTestCase() +{ + m_manager = NetworkRequestManager::globalInstance(); + QVERIFY(m_manager != nullptr); +} + +void BodyTypeTests::cleanupTestCase() +{ +} + +void BodyTypeTests::waitForResponse(int timeoutMs) +{ + m_responseReceived = false; + m_lastResult.clear(); + QElapsedTimer timer; + timer.start(); + while (!m_responseReceived && timer.elapsed() < timeoutMs) + { + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + } +} + +// ============================================================================ +// No-network builder validation tests +// ============================================================================ + +void BodyTypeTests::testBodyJsonBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .bodyJson("{\"key\":\"value\"}") + .build(); + + QVERIFY(ctx->bodyType == BodyType::Json); + QCOMPARE(ctx->body, QString("{\"key\":\"value\"}")); +} + +void BodyTypeTests::testBodyXmlBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .bodyXml("value") + .build(); + + QVERIFY(ctx->bodyType == BodyType::Xml); + QCOMPARE(ctx->body, QString("value")); +} + +void BodyTypeTests::testBodyFormUrlEncodedBuilder() +{ + QMap params; + params["name"] = "John"; + params["age"] = "30"; + + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .bodyFormUrlEncoded(params) + .build(); + + QVERIFY(ctx->bodyType == BodyType::FormUrlEncoded); + // Should be URL-encoded: "name=John&age=30" (order may vary) + QVERIFY(ctx->body.contains("name=John") || ctx->body.contains("name%3DJohn")); + QVERIFY(ctx->body.contains("age=30") || ctx->body.contains("age%3D30")); +} + +void BodyTypeTests::testBodyRawBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .bodyRaw("some plain text") + .build(); + + QVERIFY(ctx->bodyType == BodyType::Raw); + QCOMPARE(ctx->body, QString("some plain text")); +} + +void BodyTypeTests::testBodyBinaryBuilder() +{ + // 11 bytes: "binary" + 0x00 + "data". Hex escape "\x00data" would greedily + // consume 'd','a' -> 0x00DA, losing the embedded null we intend to test. + char raw[] = {'b', 'i', 'n', 'a', 'r', 'y', '\0', 'd', 'a', 't', 'a'}; + QByteArray data(raw, 11); + + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .bodyBinary(data) + .build(); + + QVERIFY(ctx->bodyType == BodyType::Binary); + QCOMPARE(ctx->binaryBody, data); + QVERIFY(ctx->body.isEmpty()); +} + +void BodyTypeTests::testBodyTypeDefault() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com") + .type(RequestType::Post) + .body("some body") + .build(); + + // Default bodyType is None (backward compatible) + QVERIFY(ctx->bodyType == BodyType::None); + QCOMPARE(ctx->body, QString("some body")); +} + +// ============================================================================ +// Network integration tests +// ============================================================================ + +void BodyTypeTests::testBodyJsonContentType() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/post"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Post) + .bodyJson("{\"test\":true}") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + // Verify the server received Content-Type: application/json + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["content-type"].toString(), QString("application/json")); +} + +void BodyTypeTests::testBodyXmlContentType() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/post"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Post) + .bodyXml("") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["content-type"].toString(), QString("application/xml")); +} + +void BodyTypeTests::testBodyFormUrlEncodedContentType() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/post"; + + QMap params; + params["name"] = "test"; + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Post) + .bodyFormUrlEncoded(params) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["content-type"].toString(), QString("application/x-www-form-urlencoded")); +} + +void BodyTypeTests::testBodyBinaryRequest() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/post"; + + QByteArray binaryData("hello binary world", 18); + + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Post) + .bodyBinary(binaryData) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["content-type"].toString(), QString("application/octet-stream")); +} + +void BodyTypeTests::testBodyCTPriority() +{ + HttpTestServer server; + QVERIFY(server.start()); + + QString url = server.baseUrl() + "/post"; + + // User explicitly sets Content-Type; bodyType auto-detection should NOT override it + auto ctx = RequestContextBuilder() + .url(url) + .type(RequestType::Post) + .bodyJson("{\"test\":true}") + .header("Content-Type", "application/vnd.api+json") + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["content-type"].toString(), QString("application/vnd.api+json")); +} + +// Tests run via main_requestbuilder.cpp entry point +#include "test_bodytype.moc" diff --git a/test/test_bodytype.h b/test/test_bodytype.h new file mode 100644 index 0000000..7e2c076 --- /dev/null +++ b/test/test_bodytype.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include "requestcontext.h" +#include "networkrequestmanager.h" +#include "networkreply.h" +#include "httptestserver.h" + +class BodyTypeTests : public QObject +{ + Q_OBJECT + +public: + BodyTypeTests(); + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + + // No-network builder validation tests + void testBodyJsonBuilder(); + void testBodyXmlBuilder(); + void testBodyFormUrlEncodedBuilder(); + void testBodyRawBuilder(); + void testBodyBinaryBuilder(); + void testBodyTypeDefault(); + + // Network integration tests + void testBodyJsonContentType(); + void testBodyXmlContentType(); + void testBodyFormUrlEncodedContentType(); + void testBodyBinaryRequest(); + void testBodyCTPriority(); + +private: + void waitForResponse(int timeoutMs = 5000); + + QtNetworkRequest::NetworkRequestManager *m_manager{ nullptr }; + QSharedPointer m_lastResult; + bool m_responseReceived{ false }; +}; diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index 7852ec2..3ae8823 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -160,6 +160,105 @@ void TestQtRequester::testApplyAuthHeader() QVERIFY(!headers.contains("Authorization")); } +// --------------------------------------------------------------------------- +// 5b. buildAuthConfig produces the correct AuthConfig for ApiKey (Header/Query) +// --------------------------------------------------------------------------- +void TestQtRequester::testBuildAuthConfigApiKey() +{ + NetworkRequestTool tool; + + tool.m_settings.authType = "ApiKey"; + tool.m_settings.authApiKey = "X-Api-Key"; + tool.m_settings.authApiValue = "secret"; + tool.m_settings.authApiLocation = "Header"; + AuthConfig cfg = tool.buildAuthConfig(); + QVERIFY(cfg.type == AuthType::ApiKey); + QCOMPARE(cfg.apiKey, QString("X-Api-Key")); + QCOMPARE(cfg.apiValue, QString("secret")); + QVERIFY(cfg.apiKeyPlacement == ApiKeyPlacement::Header); + + tool.m_settings.authApiLocation = "Query"; + AuthConfig cfgQuery = tool.buildAuthConfig(); + QVERIFY(cfgQuery.type == AuthType::ApiKey); + QVERIFY(cfgQuery.apiKeyPlacement == ApiKeyPlacement::QueryParam); + + // Empty key -> None + tool.m_settings.authApiKey.clear(); + AuthConfig cfgNone = tool.buildAuthConfig(); + QVERIFY(cfgNone.type == AuthType::None); +} + +// --------------------------------------------------------------------------- +// 5c. buildRequestContext routes table_params into req->queryParams +// --------------------------------------------------------------------------- +void TestQtRequester::testBuildRequestContextQueryParams() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *paramsTable = tool.findChild("table_params"); + QVERIFY(paramsTable); + paramsTable->setRowCount(1); + paramsTable->setItem(0, 0, new QTableWidgetItem("q")); + paramsTable->setItem(0, 1, new QTableWidgetItem("hello")); + tool.ui.lineEdit_url->setText("https://example.com/api"); + + auto req = tool.buildRequestContext(); + QVERIFY(req != nullptr); + QVERIFY(req->queryParams.contains("q")); + QCOMPARE(req->queryParams.value("q"), QString("hello")); + // base URL must not contain the query string (library appends it later) + QVERIFY(!req->url.contains("q=hello")); +} + +// --------------------------------------------------------------------------- +// 5d. buildRequestContext routes a picked binary file into req->binaryBody +// --------------------------------------------------------------------------- +void TestQtRequester::testBuildRequestContextBinary() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + QTemporaryFile tmp; + tmp.open(); + // Build 7 bytes: 'A','B','C',0x00,'D','E','F'. Avoid hex escape "\x00DEF" + // (the \x escape greedily consumes all following hex digits -> out-of-range). + char raw[] = {'A', 'B', 'C', '\0', 'D', 'E', 'F'}; + QByteArray data(raw, 7); + tmp.write(data); + QString path = tmp.fileName(); + tmp.close(); + + tool.currentBodyType = "binary"; + tool.m_binaryFilePath = path; + + auto req = tool.buildRequestContext(); + QVERIFY(req != nullptr); + QVERIFY(req->bodyType == BodyType::Binary); + QCOMPARE(req->binaryBody, data); +} + +// --------------------------------------------------------------------------- +// 5e. buildRequestContext carries the authConfig from settings +// --------------------------------------------------------------------------- +void TestQtRequester::testBuildRequestContextAuth() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + tool.m_settings.authType = "Bearer"; + tool.m_settings.authToken = "tok-xyz"; + tool.ui.lineEdit_url->setText("https://example.com/api"); + + auto req = tool.buildRequestContext(); + QVERIFY(req != nullptr); + QVERIFY(req->authConfig.type == AuthType::Bearer); + QCOMPARE(req->authConfig.token, QString("tok-xyz")); +} + // --------------------------------------------------------------------------- // 6. Save / Load persistent storage // --------------------------------------------------------------------------- diff --git a/test/test_qtrequester.h b/test/test_qtrequester.h index ebb48c9..5b8431b 100644 --- a/test/test_qtrequester.h +++ b/test/test_qtrequester.h @@ -20,6 +20,10 @@ private slots: void testGetRequestBody(); void testMethodSwitchesBody(); void testApplyAuthHeader(); + void testBuildAuthConfigApiKey(); + void testBuildRequestContextQueryParams(); + void testBuildRequestContextBinary(); + void testBuildRequestContextAuth(); void testSaveAndLoadDisk(); void testEndToEndGet(); diff --git a/test/test_requestbuilder.cpp b/test/test_requestbuilder.cpp index 8181159..68c4546 100644 --- a/test/test_requestbuilder.cpp +++ b/test/test_requestbuilder.cpp @@ -17,7 +17,7 @@ void TestRequestBuilder::testBasicFields() QVERIFY(ctx != nullptr); QCOMPARE(ctx->url, QString("http://example.com/api")); - QCOMPARE(ctx->type, RequestType::Post); + QVERIFY(ctx->type == RequestType::Post); QCOMPARE(ctx->body, QString("hello=world")); } @@ -132,7 +132,7 @@ void TestRequestBuilder::testConfigsAndUserContext() QCOMPARE(ctx->userContext.toString(), QString("ctx-token")); #ifndef QT_NO_SSL QVERIFY(ctx->sslConfig != nullptr); - QCOMPARE(ctx->sslConfig->peerVerifyMode, SslConfig::PeerVerifyMode::VerifyNone); + QVERIFY(ctx->sslConfig->peerVerifyMode == SslConfig::PeerVerifyMode::VerifyNone); #endif } @@ -147,3 +147,56 @@ void TestRequestBuilder::testBuildConsumesBuilder() auto second = builder.build(); QVERIFY(second == nullptr); } + +void TestRequestBuilder::testQueryParamBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com/api") + .type(RequestType::Get) + .queryParam("page", "1") + .queryParam("limit", "50") + .build(); + + QCOMPARE(ctx->queryParams.size(), 2); + QCOMPARE(ctx->queryParams["page"], QString("1")); + QCOMPARE(ctx->queryParams["limit"], QString("50")); +} + +void TestRequestBuilder::testQueryParamsMapBuilder() +{ + QMap params; + params["sort"] = "desc"; + params["filter"] = "active"; + + auto ctx = RequestContextBuilder() + .url("https://example.com/api") + .type(RequestType::Get) + .queryParams(params) + .build(); + + QCOMPARE(ctx->queryParams.size(), 2); + QCOMPARE(ctx->queryParams["sort"], QString("desc")); + QCOMPARE(ctx->queryParams["filter"], QString("active")); +} + +void TestRequestBuilder::testAuthBuilder() +{ + auto ctx = RequestContextBuilder() + .url("https://example.com/api") + .type(RequestType::Get) + .authBearer("test-token") + .build(); + + QVERIFY(ctx->authConfig.type == AuthType::Bearer); + QCOMPARE(ctx->authConfig.token, QString("test-token")); + + // Verify builder can also set via AuthConfig directly + auto ctx2 = RequestContextBuilder() + .url("https://example.com/api") + .type(RequestType::Get) + .authConfig(AuthConfig::basic("admin", "123")) + .build(); + + QVERIFY(ctx2->authConfig.type == AuthType::Basic); + QCOMPARE(ctx2->authConfig.username, QString("admin")); +} diff --git a/test/test_requestbuilder.h b/test/test_requestbuilder.h index 4261f18..f4604d4 100644 --- a/test/test_requestbuilder.h +++ b/test/test_requestbuilder.h @@ -19,6 +19,9 @@ private slots: void testTaskAndSessionIds(); void testConfigsAndUserContext(); void testBuildConsumesBuilder(); + void testQueryParamBuilder(); + void testQueryParamsMapBuilder(); + void testAuthBuilder(); }; #endif // TEST_REQUESTBUILDER_H From 477a588344e3d5b5d80bba3fb331e37ad77d783b Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 12:33:06 +0800 Subject: [PATCH 02/12] feat(gui): request-body syntax highlighting, FormData builder symmetry, cleanup and test.bat - Add NetworkRequestTool::applyBodySyntaxHighlighting to colorize raw JSON/XML request bodies (reuses JsonSyntaxHighlighter/XmlSyntaxHighlighter); clears the 4 TODO placeholders in onBodyTypeChanged/onRawTypeChanged - Add RequestContextBuilder::bodyFormData(files, kvPairs) so BodyType::FormData has a symmetric builder; add testBodyFormDataBuilder (BuilderTests) - Section F cleanup: merge duplicate cmb_body_type slot onBodyTypeComboChanged into onBodyTypeChanged (also fixes cmb_raw_type left enabled after binary), and remove the duplicate table_headers cellChanged lambda in the constructor - Add scripts/test.bat (Windows counterpart of test_local.sh): Qt/VS auto-detect, configure + build + ctest, with /clean /nobuild /debug /help and ctest exit code propagated --- include/requestcontext.h | 15 ++ .../networkrequesttool/networkrequesttool.cpp | 74 +++----- .../networkrequesttool/networkrequesttool.h | 2 +- scripts/test.bat | 175 ++++++++++++++++++ test/test_requestbuilder.cpp | 25 +++ test/test_requestbuilder.h | 1 + 6 files changed, 246 insertions(+), 46 deletions(-) create mode 100644 scripts/test.bat diff --git a/include/requestcontext.h b/include/requestcontext.h index 991d5de..ce54af5 100644 --- a/include/requestcontext.h +++ b/include/requestcontext.h @@ -227,6 +227,21 @@ namespace QtNetworkRequest m_context->bodyType = BodyType::Binary; return *this; } + /// Build a multipart/form-data body from file parts and key/value pairs. + /// Assembles an UploadConfig (useFormData=true) so the pipeline produces + /// the multipart payload + boundary. This mirrors the FormData enum value + /// to keep the BodyType API symmetric with the other body*() methods. + RequestContextBuilder &bodyFormData(const QStringList &files, + const QMap &kvPairs) + { + std::unique_ptr up = std::make_unique(); + up->useFormData = true; + up->files = files; + up->kvPairs = kvPairs; + m_context->uploadConfig = std::move(up); + m_context->bodyType = BodyType::FormData; + return *this; + } // --- cookies --- RequestContextBuilder &cookie(const QNetworkCookie &v) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index 55c8395..6bdccf2 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -75,16 +75,6 @@ NetworkRequestTool::NetworkRequestTool(QWidget *parent) { ui.setupUi(this); initialize(); - - // Connect table cell changed signal to listen for Content-Type header changes - connect(ui.table_headers, &QTableWidget::cellChanged, this, [=](int row, int column) - { - QTableWidgetItem *keyItem = ui.table_headers->item(row, 0); - QTableWidgetItem *valueItem = ui.table_headers->item(row, 1); - if (keyItem && valueItem && keyItem->text().toLower() == "content-type") { - // Update Body type when Content-Type header changes - updateBodyTypeFromContentType(valueItem->text()); - } }); } NetworkRequestTool::~NetworkRequestTool() @@ -196,7 +186,6 @@ void NetworkRequestTool::initializeConnections() ui.lineEdit_binary_path->setText(path); } }); - connect(ui.cmb_body_type, &QComboBox::currentTextChanged, this, &NetworkRequestTool::onBodyTypeComboChanged); connect(ui.table_body, &QTableWidget::cellChanged, this, &NetworkRequestTool::onBodyParamTypeChanged); // Connect table cell changed signal to listen for Content-Type header changes @@ -358,11 +347,13 @@ void NetworkRequestTool::onBodyTypeChanged(const QString &bodyType) ui.stackedWidget_body->setCurrentWidget(ui.page_raw); ui.textEdit_body->clear(); ui.textEdit_body->setEnabled(false); + ui.cmb_raw_type->setEnabled(false); } else if (bodyType == "raw") { ui.stackedWidget_body->setCurrentWidget(ui.page_raw); ui.textEdit_body->setEnabled(true); + ui.cmb_raw_type->setEnabled(true); // Ensure JSON auto-formatting function is correctly connected in initial state if (currentRawType == "JSON") { @@ -373,11 +364,13 @@ void NetworkRequestTool::onBodyTypeChanged(const QString &bodyType) { ui.stackedWidget_body->setCurrentWidget(ui.page_binary); ui.textEdit_body->setEnabled(false); + ui.cmb_raw_type->setEnabled(false); } else { ui.stackedWidget_body->setCurrentWidget(ui.page_form); ui.textEdit_body->setEnabled(false); + ui.cmb_raw_type->setEnabled(false); } // Update Content-Type header @@ -388,19 +381,20 @@ void NetworkRequestTool::onBodyTypeChanged(const QString &bodyType) { if (currentRawType == "JSON") { - // TODO: Set JSON syntax highlighting // Connect textChanged signal to implement auto-formatting connect(ui.textEdit_body, &QTextEdit::textChanged, this, &NetworkRequestTool::onBodyTextChanged, Qt::UniqueConnection); } - else if (currentRawType == "XML") - { - // TODO: Set XML syntax highlighting - } else { // Disconnect to avoid triggering in non-JSON mode disconnect(ui.textEdit_body, &QTextEdit::textChanged, this, &NetworkRequestTool::onBodyTextChanged); } + applyBodySyntaxHighlighting(currentRawType); + } + else + { + // Non-raw body types (binary / form-data) use no syntax highlighting + m_highlighter.reset(); } } @@ -412,7 +406,6 @@ void NetworkRequestTool::onRawTypeChanged(const QString &type) // Set appropriate syntax highlighting if (type == "JSON") { - // TODO: Set JSON syntax highlighting // Connect textChanged signal to implement auto-formatting connect(ui.textEdit_body, &QTextEdit::textChanged, this, &NetworkRequestTool::onBodyTextChanged, Qt::UniqueConnection); } @@ -420,10 +413,25 @@ void NetworkRequestTool::onRawTypeChanged(const QString &type) { // Disconnect to avoid triggering in non-JSON mode disconnect(ui.textEdit_body, &QTextEdit::textChanged, this, &NetworkRequestTool::onBodyTextChanged); - if (type == "XML") - { - // TODO: Set XML syntax highlighting - } + } + applyBodySyntaxHighlighting(type); +} + +void NetworkRequestTool::applyBodySyntaxHighlighting(const QString &rawType) +{ + // Recreate the highlighter bound to the request body document. Reassigning + // a new unique_ptr replaces (and thereby clears) any previous highlighter. + if (rawType == "JSON") + { + m_highlighter = std::make_unique(ui.textEdit_body->document()); + } + else if (rawType == "XML") + { + m_highlighter = std::make_unique(ui.textEdit_body->document()); + } + else + { + m_highlighter.reset(); } } @@ -1221,30 +1229,6 @@ QString NetworkRequestTool::formatDateTime(const QDateTime &dateTime) return dateTime.toString("yyyy-MM-dd HH:mm:ss"); } -void NetworkRequestTool::onBodyTypeComboChanged(const QString &type) -{ - if (type == "raw") - { - ui.stackedWidget_body->setCurrentWidget(ui.page_raw); - ui.cmb_raw_type->setEnabled(true); - } - else if (type == "none") - { - ui.stackedWidget_body->setCurrentWidget(ui.page_raw); - ui.cmb_raw_type->setEnabled(false); - ui.textEdit_body->clear(); - } - else if (type == "binary") - { - ui.stackedWidget_body->setCurrentWidget(ui.page_binary); - } - else - { - ui.stackedWidget_body->setCurrentWidget(ui.page_form); - ui.cmb_raw_type->setEnabled(false); - } -} - void NetworkRequestTool::onAddBodyParam() { int row = ui.table_body->rowCount(); diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 94d72b4..220e6a8 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -84,7 +84,6 @@ private slots: void onAbortAllTask(); void onAddBodyParam(); void onRemoveBodyParam(); - void onBodyTypeComboChanged(const QString &type); void onBodyParamTypeChanged(int row, int column); private: @@ -95,6 +94,7 @@ private slots: void setupDefaultValues(); void addDefaultHeaders(); void updateContentTypeHeader(); + void applyBodySyntaxHighlighting(const QString &rawType); void updateBodyTypeFromContentType(const QString &contentType); void updateHeader(const QString &key, const QString &value); QString buildUrlWithParams(); diff --git a/scripts/test.bat b/scripts/test.bat new file mode 100644 index 0000000..f459a4c --- /dev/null +++ b/scripts/test.bat @@ -0,0 +1,175 @@ +@echo off +setlocal EnableDelayedExpansion + +REM ============================================================================ +REM test.bat - Configure, build and run the QtMultiThreadNetwork test suite (Windows) +REM +REM This is the Windows equivalent of scripts/test_local.sh. It reuses the same +REM Qt / Visual Studio detection logic as scripts/build_win.bat. +REM +REM Usage: +REM test.bat Configure + build (Release) + run ctest +REM test.bat /clean Remove ..\build first, then configure + build + test +REM test.bat /nobuild Skip the build step, run ctest against existing build +REM test.bat /debug Use Debug configuration instead of Release +REM test.bat /help Show this help +REM +REM Environment: +REM QT_DIR (or QTDIR) Path to the Qt installation (e.g. C:\Qt\5.15.2\msvc2019_64) +REM ============================================================================ + +REM ---- Parse arguments ---- +set CONFIG=Release +set DO_CLEAN=0 +set SKIP_BUILD=0 + +if "%~1"=="/help" goto :show_help +if "%~1"=="-h" goto :show_help +if "%~1"=="--help" goto :show_help + +:parse_args +if "%~1"=="" goto :args_done +if /i "%~1"=="/clean" ( set DO_CLEAN=1 & shift & goto :parse_args ) +if /i "%~1"=="/nobuild" ( set SKIP_BUILD=1 & shift & goto :parse_args ) +if /i "%~1"=="/debug" ( set CONFIG=Debug & shift & goto :parse_args ) +echo Unknown argument: %~1 +goto :show_help +:args_done + +REM ---- Locate script directory and change into it ---- +set SCRIPT_DIR=%~dp0 +cd /d "%SCRIPT_DIR%" +echo Running from: "%SCRIPT_DIR%" + +REM ---- Check for Qt environment variables ---- +if not defined QT_DIR ( + if not defined QTDIR ( + echo Error: Qt environment variable not set! + echo Please set QT_DIR or QTDIR to your Qt installation. + echo Example: set QT_DIR=C:\Qt\5.15.2\msvc2019_64 + pause + exit /b 1 + ) else ( + set QT_DIR=%QTDIR% + ) +) + +if not exist "%QT_DIR%" ( + echo Error: Qt directory not found: %QT_DIR% + echo Please check your QT_DIR environment variable. + pause + exit /b 1 +) + +REM Add Qt bin directory to PATH +set PATH=%QT_DIR%\bin;%PATH% +echo Using Qt from: %QT_DIR% + +REM ---- Detect Visual Studio version ---- +echo Detecting Visual Studio... +set VS_VERSION= +set VS_GENERATOR= + +if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Professional +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Professional (x86) +) else if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Community +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Community (x86) +) else if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Professional +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Professional (x86) +) else if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Community +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" ( + set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Community (x86) +) else if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" ( + set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Professional +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" ( + set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Professional (x86) +) else if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" ( + set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Community +) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" ( + set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Community (x86) +) + +if not defined VS_VERSION ( + echo Error: No Visual Studio installation found! + echo Please install Visual Studio 2017, 2019, or 2022. + pause + exit /b 1 +) +echo Using Visual Studio %VS_VERSION% with generator %VS_GENERATOR% + +REM ---- Optional clean ---- +if %DO_CLEAN%==1 ( + if exist ..\build ( + echo Removing existing build directory... + rmdir /s /q ..\build + ) +) + +REM ---- Configure ---- +echo Configuring project (config=%CONFIG%)... +cmake -S .. -B ..\build -G %VS_GENERATOR% -A x64 -DCMAKE_BUILD_TYPE=%CONFIG% +if !errorlevel! neq 0 ( + echo CMake configuration failed! + pause + exit /b 1 +) + +REM ---- Build ---- +if %SKIP_BUILD%==0 ( + echo Building project... + cmake --build ..\build --config %CONFIG% --parallel + if !errorlevel! neq 0 ( + echo Build failed! + pause + exit /b 1 + ) +) else ( + echo Skipping build step (run ctest against existing build)... +) + +REM ---- Run tests ---- +echo Running tests with ctest... +pushd ..\build +ctest -C %CONFIG% --output-on-failure +set TEST_RESULT=!errorlevel! +popd + +if !TEST_RESULT! neq 0 ( + echo. + echo ============================================================ + echo Some tests FAILED (ctest exit code: !TEST_RESULT!^) + echo ============================================================ + pause + exit /b !TEST_RESULT! +) + +echo. +echo ============================================================ +echo All tests passed. +echo ============================================================ +endlocal +exit /b 0 + +:show_help +echo. +echo test.bat - Configure, build and run the test suite (Windows) +echo. +echo Usage: +echo test.bat Configure + build (Release) + run ctest +echo test.bat /clean Remove ..\build first, then configure + build + test +echo test.bat /nobuild Skip the build step, run ctest against existing build +echo test.bat /debug Use Debug configuration instead of Release +echo test.bat /help Show this help +echo. +echo Environment: +echo QT_DIR (or QTDIR) Path to the Qt installation +echo. +endlocal +exit /b 0 diff --git a/test/test_requestbuilder.cpp b/test/test_requestbuilder.cpp index 68c4546..a613d8f 100644 --- a/test/test_requestbuilder.cpp +++ b/test/test_requestbuilder.cpp @@ -200,3 +200,28 @@ void TestRequestBuilder::testAuthBuilder() QVERIFY(ctx2->authConfig.type == AuthType::Basic); QCOMPARE(ctx2->authConfig.username, QString("admin")); } + +void TestRequestBuilder::testBodyFormDataBuilder() +{ + QStringList files = {QStringLiteral("/tmp/a.png"), QStringLiteral("/tmp/b.zip")}; + QMap kv; + kv["field1"] = "value1"; + kv["field2"] = "value2"; + + auto ctx = RequestContextBuilder() + .url("https://example.com/upload") + .type(RequestType::Post) + .bodyFormData(files, kv) + .build(); + + // bodyType must be symmetric with the FormData enum value. + QVERIFY(ctx->bodyType == BodyType::FormData); + // uploadConfig carries the multipart parts. + QVERIFY(ctx->uploadConfig != nullptr); + QVERIFY(ctx->uploadConfig->useFormData); + QCOMPARE(ctx->uploadConfig->files, files); + QCOMPARE(ctx->uploadConfig->kvPairs, kv); + // No automatic Content-Type is set for FormData (boundary is added by the + // pipeline), so the body string stays empty here. + QVERIFY(ctx->body.isEmpty()); +} diff --git a/test/test_requestbuilder.h b/test/test_requestbuilder.h index f4604d4..91cd8be 100644 --- a/test/test_requestbuilder.h +++ b/test/test_requestbuilder.h @@ -22,6 +22,7 @@ private slots: void testQueryParamBuilder(); void testQueryParamsMapBuilder(); void testAuthBuilder(); + void testBodyFormDataBuilder(); }; #endif // TEST_REQUESTBUILDER_H From a5fcd7bf8a66e36790f3670b6ff57b47aaebdefc Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 17:22:49 +0800 Subject: [PATCH 03/12] M0: shared logic layer + unit tests (OAuth2 / env vars / Collections / Postman v2.1) New files: - include/authconfig.h: OAuth2Config with 4 grant types + bearer token header - include/environment.h, source/environment.cpp: env var substitution ({{var}} in url/headers/body/queryParams/auth fields) - include/oauth2tokencache.h, source/oauth2tokencache.cpp: Q_GLOBAL_STATIC singleton token cache with expiry - include/collectionmodel.h, source/collectionmodel.cpp: in-memory Collection tree (folders + requests) with JSON serde, findById, add/remove - include/postmanconverter.h, source/postmanconverter.cpp: Postman v2.1 bidirectional converter (auth round-trip, nested folders, request mapping) - test/test_environment.*, test/test_collection.*, test/test_postman.*, test/main_collection.cpp: BuilderTests env cases + CollectionTests Modified: - include/requestcontext.h: BodyType::FormData + bodyFormData() builder - source/CMakeLists.txt, test/CMakeLists.txt: added new sources - scripts/test.bat: Windows test runner (walk-up repo root, goto VS detect, no EnableDelayedExpansion, echo brackets avoided) Fixes across 4 compile rounds: struct order, member/method name collision, Q_GLOBAL_STATIC access, QJsonArray::reserve, Qt5.15 QString::replace API, missing QJsonArray/QJsonObject includes, Postman import empty-id collision. All 7 test suites pass. --- include/authconfig.h | 46 +++++++ include/collectionmodel.h | 98 +++++++++++++++ include/environment.h | 46 +++++++ include/oauth2tokencache.h | 77 ++++++++++++ include/postmanconverter.h | 53 ++++++++ include/requestcontext.h | 13 ++ scripts/test.bat | 130 ++++++++++--------- source/CMakeLists.txt | 8 ++ source/collectionmodel.cpp | 234 +++++++++++++++++++++++++++++++++++ source/environment.cpp | 111 +++++++++++++++++ source/oauth2tokencache.cpp | 68 ++++++++++ source/postmanconverter.cpp | 205 ++++++++++++++++++++++++++++++ test/CMakeLists.txt | 32 +++++ test/main_collection.cpp | 21 ++++ test/main_requestbuilder.cpp | 3 + test/test_collection.cpp | 85 +++++++++++++ test/test_collection.h | 18 +++ test/test_environment.cpp | 132 ++++++++++++++++++++ test/test_environment.h | 23 ++++ test/test_postman.cpp | 198 +++++++++++++++++++++++++++++ test/test_postman.h | 18 +++ 21 files changed, 1562 insertions(+), 57 deletions(-) create mode 100644 include/collectionmodel.h create mode 100644 include/environment.h create mode 100644 include/oauth2tokencache.h create mode 100644 include/postmanconverter.h create mode 100644 source/collectionmodel.cpp create mode 100644 source/environment.cpp create mode 100644 source/oauth2tokencache.cpp create mode 100644 source/postmanconverter.cpp create mode 100644 test/main_collection.cpp create mode 100644 test/test_collection.cpp create mode 100644 test/test_collection.h create mode 100644 test/test_environment.cpp create mode 100644 test/test_environment.h create mode 100644 test/test_postman.cpp create mode 100644 test/test_postman.h diff --git a/include/authconfig.h b/include/authconfig.h index 50966df..2eb7a49 100644 --- a/include/authconfig.h +++ b/include/authconfig.h @@ -40,6 +40,7 @@ namespace QtNetworkRequest Basic = 1, // HTTP Basic Authentication (RFC 7617) Bearer = 2, // Bearer Token Authentication (RFC 6750) ApiKey = 3, // Custom API Key in Header or Query Param + OAuth2 = 4, // OAuth 2.0 (client-credentials / password / refresh-token) }; /// Placement strategy for API Key authentication @@ -49,6 +50,15 @@ namespace QtNetworkRequest QueryParam = 1, // Append to URL query string }; + /// OAuth 2.0 grant types supported by AuthConfig::OAuth2. + /// (Authorization Code + PKCE is intentionally out of scope for v1.) + enum class OAuth2GrantType : int8_t + { + ClientCredentials = 0, // client_credentials + Password = 1, // Resource Owner Password Credentials (ROPC) + RefreshToken = 2, // refresh_token (also used for auto-renew) + }; + /// Authentication configuration (value type, read-only after RequestContext build) struct AuthConfig { @@ -67,6 +77,24 @@ namespace QtNetworkRequest QString apiValue; // Header value or query param value ApiKeyPlacement apiKeyPlacement{ ApiKeyPlacement::Header }; + /// OAuth 2.0 grant configuration (client-credentials / password / refresh-token). + struct OAuth2Config + { + OAuth2GrantType grant{ OAuth2GrantType::ClientCredentials }; + QString clientId; + QString clientSecret; + QString scopes; // space-separated scope list + QString tokenUrl; // absolute token endpoint + // Password grant + QString username; + QString password; + // Refresh grant (also used for auto-renew of any grant) + QString refreshToken; + }; + + // --- OAuth2 --- + OAuth2Config oauth2Config; + // --- Factory methods --- /// Create a Basic Authentication config. @@ -106,6 +134,15 @@ namespace QtNetworkRequest return c; } + /// Create an OAuth 2.0 config from a grant configuration. + static AuthConfig oauth2(const OAuth2Config &cfg) + { + AuthConfig c; + c.type = AuthType::OAuth2; + c.oauth2Config = cfg; + return c; + } + /// Validate that required credentials are present for the selected auth type. /// AuthType::None is always considered valid. bool isValid() const @@ -115,6 +152,11 @@ namespace QtNetworkRequest case AuthType::Basic: return !username.isEmpty(); case AuthType::Bearer: return !token.isEmpty(); case AuthType::ApiKey: return !apiKey.isEmpty() && !apiValue.isEmpty(); + case AuthType::OAuth2: + if (oauth2Config.grant == OAuth2GrantType::Password) + return !oauth2Config.tokenUrl.isEmpty() && !oauth2Config.clientId.isEmpty() + && !oauth2Config.username.isEmpty() && !oauth2Config.password.isEmpty(); + return !oauth2Config.tokenUrl.isEmpty() && !oauth2Config.clientId.isEmpty(); case AuthType::None: return true; default: return false; } @@ -135,6 +177,10 @@ namespace QtNetworkRequest } case AuthType::Bearer: return QByteArrayLiteral("Bearer ") + token.toUtf8(); + case AuthType::OAuth2: + // token is populated at runtime by the async OAuth2 token fetch (M2); + // until then it is empty and the header is simply "Bearer ". + return QByteArrayLiteral("Bearer ") + token.toUtf8(); default: return {}; } diff --git a/include/collectionmodel.h b/include/collectionmodel.h new file mode 100644 index 0000000..265b95a --- /dev/null +++ b/include/collectionmodel.h @@ -0,0 +1,98 @@ +/* +@Brief: Qt multi-threaded network request module – request Collection model + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include +#include +#include +#include "networkrequestglobal.h" + +namespace QtNetworkRequest +{ + enum class CollectionItemType + { + Folder, + Request + }; + + /// A node in a Collection tree. Folders contain children; Requests carry a + /// `requestJson` object whose fields match the on-disk single-request schema + /// (method/url/body/bodyType/rawType/timestamp/params[]/headers[]/auth). + struct CollectionItem + { + CollectionItemType type{ CollectionItemType::Folder }; + QString id; // stable id for tree mapping / lookup + QString name; + QJsonObject requestJson; // for Request items + QList children; // for Folder items + }; + + /// In-memory Collection (folders + requests) with JSON (de)serialization. + /// Backs both the GUI tree and the Postman v2.1 converter. + class NETWORK_EXPORT Collection + { + public: + Collection() = default; + explicit Collection(const QString &name) : m_name(name) {} + + const QString &name() const { return m_name; } + void setName(const QString &name) { m_name = name; } + + const CollectionItem &root() const { return m_root; } + CollectionItem &root() { return m_root; } + + /// Load/save the whole collection from/to a JSON file. + bool load(const QString &path); + bool save(const QString &path) const; + + /// Mutators return the new item's id (empty string on failure). + QString addFolder(const QString &parentId, const QString &name); + QString addRequest(const QString &parentId, const QString &name, + const QJsonObject &requestJson); + /// Remove the item with the given id anywhere in the tree. + void removeItem(const QString &id); + + /// Lookup by id (navigates from root). Returns nullptr if not found. + CollectionItem *findById(const QString &id); + const CollectionItem *findById(const QString &id) const; + + /// Generate a stable unique id (UUID without braces). + static QString genId(); + + /// (De)serialize a single item (shared by Collection + PostmanConverter). + static QJsonObject toJson(const CollectionItem &item); + static CollectionItem fromJson(const QJsonObject &obj, bool *ok = nullptr); + + private: + CollectionItem *findById(CollectionItem &node, const QString &id); + const CollectionItem *findById(const CollectionItem &node, const QString &id) const; + bool removeFrom(CollectionItem &parent, const QString &id); + + QString m_name; + CollectionItem m_root{ CollectionItemType::Folder, QString(), QStringLiteral("root") }; + }; +} diff --git a/include/environment.h b/include/environment.h new file mode 100644 index 0000000..b2e82a0 --- /dev/null +++ b/include/environment.h @@ -0,0 +1,46 @@ +/* +@Brief: Qt multi-threaded network request module – environment variable substitution + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include +#include +#include "requestcontext.h" // RequestContext (authConfig, headers, ...) +#include "authconfig.h" +#include "networkrequestglobal.h" + +namespace QtNetworkRequest +{ + /// Replace all `{{key}}` occurrences in `text` using `vars`. + /// Unknown keys are left intact (Postman-style, so misconfiguration is visible). + /// A single pass is performed — values are NOT recursively substituted. + NETWORK_EXPORT QString substituteEnv(const QString &text, const QMap &vars); + + /// Substitute `{{var}}` across a whole RequestContext: url, header values, body, + /// query params, and the relevant AuthConfig fields (R3). `binaryBody` is never + /// touched, because env substitution must not corrupt binary payloads. + NETWORK_EXPORT void applyEnvironment(RequestContext &ctx, const QMap &vars); +} diff --git a/include/oauth2tokencache.h b/include/oauth2tokencache.h new file mode 100644 index 0000000..b590cbe --- /dev/null +++ b/include/oauth2tokencache.h @@ -0,0 +1,77 @@ +/* +@Brief: Qt multi-threaded network request module – OAuth2 token cache + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include +#include +#include +#include +#include "networkrequestglobal.h" + +namespace QtNetworkRequest +{ + /// A cached OAuth2 token pair with an absolute expiry timestamp. + struct NETWORK_EXPORT OAuth2TokenEntry + { + QString accessToken; + QString refreshToken; + QDateTime expiresAt; // absolute expiry; isExpired() compares to now() + + bool isExpired() const { return QDateTime::currentDateTime() >= expiresAt; } + bool isValid() const { return !accessToken.isEmpty() && !isExpired(); } + }; + + /// Process-wide, thread-safe OAuth2 token cache. + /// + /// Tokens are cached in memory only (never persisted to disk) and keyed by + /// tokenUrl|clientId|grant|scopes. A single QMutex guards all reads/writes so + /// the cache is safe to share across the worker threads that run requests. + class NETWORK_EXPORT OAuth2TokenCache + { + public: + OAuth2TokenCache() = default; + Q_DISABLE_COPY(OAuth2TokenCache) + + /// Process-wide singleton (tokens survive across requests and managers). + static OAuth2TokenCache &instance(); + + /// Build a stable cache key from the identifying grant parameters. + static QString makeKey(const QString &tokenUrl, const QString &clientId, + const QString &grant, const QString &scopes); + + /// Look up a cached entry. Returns false if absent. `out` is filled on success. + bool find(const QString &key, OAuth2TokenEntry &out) const; + /// Store (or replace) a cached entry. + void store(const QString &key, const OAuth2TokenEntry &entry); + /// Drop all cached entries (e.g. on logout / config change). + void clear(); + + private: + mutable QMutex m_mutex; + QMap m_entries; + }; +} diff --git a/include/postmanconverter.h b/include/postmanconverter.h new file mode 100644 index 0000000..b38d7c9 --- /dev/null +++ b/include/postmanconverter.h @@ -0,0 +1,53 @@ +/* +@Brief: Qt multi-threaded network request module – Postman v2.1 converter + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include "collectionmodel.h" +#include "networkrequestglobal.h" + +namespace QtNetworkRequest +{ + /// Bidirectional converter between our Collection model and Postman v2.1 + /// collection JSON. The per-request field mapping mirrors the on-disk + /// single-request schema (method / url / body / headers / auth). + class NETWORK_EXPORT PostmanConverter + { + public: + /// Collection -> Postman v2.1 collection JSON. + static QJsonObject toPostmanV21(const Collection &c); + /// Postman v2.1 collection JSON -> Collection. *ok is set to false on a + /// structural parse failure (still returns a best-effort Collection). + static Collection fromPostmanV21(const QJsonObject &doc, bool *ok = nullptr); + + private: + static QJsonObject itemToPostman(const CollectionItem &item); + static CollectionItem postmanToItem(const QJsonObject &obj, bool &ok); + + static QJsonObject requestToPostman(const QJsonObject &requestJson); + static QJsonObject postmanToRequestJson(const QJsonObject &requestObj); + }; +} diff --git a/include/requestcontext.h b/include/requestcontext.h index ce54af5..a31c6ea 100644 --- a/include/requestcontext.h +++ b/include/requestcontext.h @@ -88,6 +88,10 @@ namespace QtNetworkRequest // Query parameters (auto-appended to URL) QMap queryParams; + // Environment variable map ({{key}} -> value) applied by the pipeline + // before the URL/headers/body/query/auth are finalized (see environment.cpp). + QMap environment; + TaskData task; // 行为配置 @@ -285,6 +289,15 @@ namespace QtNetworkRequest return *this; } + /// Set the active environment variable map ({{key}} -> value). + /// The pipeline substitutes these into url/headers/body/queryParams/auth + /// before the request is finalized. + RequestContextBuilder &environment(const QMap &v) + { + m_context->environment = v; + return *this; + } + // --- task --- RequestContextBuilder &taskId(quint64 v) { diff --git a/scripts/test.bat b/scripts/test.bat index f459a4c..fb0d9a2 100644 --- a/scripts/test.bat +++ b/scripts/test.bat @@ -1,5 +1,5 @@ @echo off -setlocal EnableDelayedExpansion +setlocal REM ============================================================================ REM test.bat - Configure, build and run the QtMultiThreadNetwork test suite (Windows) @@ -9,7 +9,7 @@ REM Qt / Visual Studio detection logic as scripts/build_win.bat. REM REM Usage: REM test.bat Configure + build (Release) + run ctest -REM test.bat /clean Remove ..\build first, then configure + build + test +REM test.bat /clean Remove \build first, then configure + build + test REM test.bat /nobuild Skip the build step, run ctest against existing build REM test.bat /debug Use Debug configuration instead of Release REM test.bat /help Show this help @@ -36,10 +36,27 @@ echo Unknown argument: %~1 goto :show_help :args_done -REM ---- Locate script directory and change into it ---- -set SCRIPT_DIR=%~dp0 -cd /d "%SCRIPT_DIR%" -echo Running from: "%SCRIPT_DIR%" +REM ---- Resolve the repository root by walking up from %CD% ---- +REM %~dp0 / %~f0 are NOT used: when PowerShell invokes cmd.exe the %0 +REM expansion is unreliable and always degrades to the drive root (e.g. "D:\"). +REM Instead we search upward from the current working directory for +REM CMakeLists.txt, which is the repo-root marker. +set "REPO_ROOT=%CD%" +:find_cmake +if exist "%REPO_ROOT%\CMakeLists.txt" goto :found_repo +REM Go up one level (%%~fI normalizes ".." to absolute path) +for %%I in ("%REPO_ROOT%\..") do set "REPO_ROOT=%%~fI" +REM Stop if we hit a drive root (e.g. D:\) -- no CMakeLists.txt there either +for %%I in ("%REPO_ROOT%") do if /i "%%~dI\"=="%%~dpI" goto :cmake_not_found +goto :find_cmake +:cmake_not_found +echo Error: Could not find CMakeLists.txt by walking up from "%CD%" +echo Please run this script from within the repository tree. +pause +exit /b 1 + +:found_repo +echo Running from: "%REPO_ROOT%" REM ---- Check for Qt environment variables ---- if not defined QT_DIR ( @@ -65,57 +82,56 @@ REM Add Qt bin directory to PATH set PATH=%QT_DIR%\bin;%PATH% echo Using Qt from: %QT_DIR% -REM ---- Detect Visual Studio version ---- +REM ---- Detect Visual Studio version (prefer newest: 2022 > 2019 > 2017) ---- +REM A goto-based cascade is used instead of a long "if/else if" chain: the chain +REM proved unreliable and fell through to the last (oldest) match. Here the first +REM existing install wins, so the newest available Visual Studio is selected. echo Detecting Visual Studio... set VS_VERSION= set VS_GENERATOR= -if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Professional -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Professional (x86) -) else if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Community -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2022 & set VS_GENERATOR="Visual Studio 17 2022" & echo Found Visual Studio 2022 Community (x86) -) else if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Professional -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Professional (x86) -) else if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Community -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" ( - set VS_VERSION=2019 & set VS_GENERATOR="Visual Studio 16 2019" & echo Found Visual Studio 2019 Community (x86) -) else if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" ( - set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Professional -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" ( - set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Professional (x86) -) else if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" ( - set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Community -) else if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" ( - set VS_VERSION=2017 & set VS_GENERATOR="Visual Studio 15 2017" & echo Found Visual Studio 2017 Community (x86) -) - -if not defined VS_VERSION ( - echo Error: No Visual Studio installation found! - echo Please install Visual Studio 2017, 2019, or 2022. - pause - exit /b 1 -) -echo Using Visual Studio %VS_VERSION% with generator %VS_GENERATOR% +if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" goto :vs2022 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe" goto :vs2022 +if exist "%ProgramFiles%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" goto :vs2022 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" goto :vs2022 +if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" goto :vs2019 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe" goto :vs2019 +if exist "%ProgramFiles%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" goto :vs2019 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe" goto :vs2019 +if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" goto :vs2017 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Professional\MSBuild\15.0\Bin\MSBuild.exe" goto :vs2017 +if exist "%ProgramFiles%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" goto :vs2017 +if exist "%ProgramFiles(x86)%\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe" goto :vs2017 +goto :vs_missing + +:vs2022 +set VS_VERSION=2022 & set "VS_GENERATOR=Visual Studio 17 2022" & echo Found Visual Studio 2022 & goto :vs_done +:vs2019 +set VS_VERSION=2019 & set "VS_GENERATOR=Visual Studio 16 2019" & echo Found Visual Studio 2019 & goto :vs_done +:vs2017 +set VS_VERSION=2017 & set "VS_GENERATOR=Visual Studio 15 2017" & echo Found Visual Studio 2017 & goto :vs_done + +:vs_missing +echo Error: No Visual Studio installation found! +echo Please install Visual Studio 2017, 2019, or 2022. +pause +exit /b 1 + +:vs_done +echo Using Visual Studio %VS_VERSION% with generator "%VS_GENERATOR%" REM ---- Optional clean ---- if %DO_CLEAN%==1 ( - if exist ..\build ( + if exist "%REPO_ROOT%\build" ( echo Removing existing build directory... - rmdir /s /q ..\build + rmdir /s /q "%REPO_ROOT%\build" ) ) REM ---- Configure ---- -echo Configuring project (config=%CONFIG%)... -cmake -S .. -B ..\build -G %VS_GENERATOR% -A x64 -DCMAKE_BUILD_TYPE=%CONFIG% -if !errorlevel! neq 0 ( +echo Configuring project [config=%CONFIG%]... +cmake -S "%REPO_ROOT%" -B "%REPO_ROOT%\build" -G "%VS_GENERATOR%" -A x64 -DCMAKE_BUILD_TYPE=%CONFIG% +if errorlevel 1 ( echo CMake configuration failed! pause exit /b 1 @@ -124,30 +140,30 @@ if !errorlevel! neq 0 ( REM ---- Build ---- if %SKIP_BUILD%==0 ( echo Building project... - cmake --build ..\build --config %CONFIG% --parallel - if !errorlevel! neq 0 ( + cmake --build "%REPO_ROOT%\build" --config %CONFIG% --parallel + if errorlevel 1 ( echo Build failed! pause exit /b 1 ) ) else ( - echo Skipping build step (run ctest against existing build)... + echo Skipping build step [run ctest against existing build]... ) REM ---- Run tests ---- echo Running tests with ctest... -pushd ..\build +pushd "%REPO_ROOT%\build" ctest -C %CONFIG% --output-on-failure -set TEST_RESULT=!errorlevel! +set TEST_RESULT=%ERRORLEVEL% popd -if !TEST_RESULT! neq 0 ( +if %TEST_RESULT% neq 0 ( echo. echo ============================================================ - echo Some tests FAILED (ctest exit code: !TEST_RESULT!^) + echo Some tests FAILED (ctest exit code: %TEST_RESULT%^) echo ============================================================ pause - exit /b !TEST_RESULT! + exit /b %TEST_RESULT% ) echo. @@ -159,17 +175,17 @@ exit /b 0 :show_help echo. -echo test.bat - Configure, build and run the test suite (Windows) +echo test.bat - Configure, build and run the test suite [Windows] echo. echo Usage: -echo test.bat Configure + build (Release) + run ctest -echo test.bat /clean Remove ..\build first, then configure + build + test +echo test.bat Configure + build [Release] + run ctest +echo test.bat /clean Remove the build dir first, then configure + build + test echo test.bat /nobuild Skip the build step, run ctest against existing build echo test.bat /debug Use Debug configuration instead of Release echo test.bat /help Show this help echo. echo Environment: -echo QT_DIR (or QTDIR) Path to the Qt installation +echo QT_DIR [or QTDIR] Path to the Qt installation echo. endlocal exit /b 0 diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 3b8beaf..1e7f829 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -20,6 +20,10 @@ add_library(QNetworkRequest SHARED progressthrottle.cpp networkrequestregistry.cpp requeststrategies.cpp + environment.cpp + oauth2tokencache.cpp + collectionmodel.cpp + postmanconverter.cpp # ── Private headers (AUTOMOC) ──────────────────────────────────────────── memorymappedfile.h @@ -51,6 +55,10 @@ add_library(QNetworkRequest SHARED ${CMAKE_SOURCE_DIR}/include/networkerror.h ${CMAKE_SOURCE_DIR}/include/networkrequestglobal.h ${CMAKE_SOURCE_DIR}/include/networkrequestevent.h + ${CMAKE_SOURCE_DIR}/include/environment.h + ${CMAKE_SOURCE_DIR}/include/oauth2tokencache.h + ${CMAKE_SOURCE_DIR}/include/collectionmodel.h + ${CMAKE_SOURCE_DIR}/include/postmanconverter.h ) # ── Compile definitions ────────────────────────────────────────────────────── diff --git a/source/collectionmodel.cpp b/source/collectionmodel.cpp new file mode 100644 index 0000000..076427c --- /dev/null +++ b/source/collectionmodel.cpp @@ -0,0 +1,234 @@ +/* +@Brief: Qt multi-threaded network request module – request Collection model + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#include "collectionmodel.h" + +#include +#include +#include +#include + +namespace QtNetworkRequest +{ + QString Collection::genId() + { + return QUuid::createUuid().toString(QUuid::WithoutBraces); + } + + QJsonObject Collection::toJson(const CollectionItem &item) + { + QJsonObject obj; + obj["type"] = (item.type == CollectionItemType::Folder) ? "folder" : "request"; + obj["id"] = item.id; + obj["name"] = item.name; + + if (item.type == CollectionItemType::Folder) + { + QJsonArray children; + for (const auto &child : item.children) + children.append(toJson(child)); + obj["children"] = children; + } + else + { + obj["request"] = item.requestJson; + } + return obj; + } + + CollectionItem Collection::fromJson(const QJsonObject &obj, bool *ok) + { + CollectionItem item; + const QString type = obj["type"].toString(); + item.id = obj["id"].toString(); + item.name = obj["name"].toString(); + + if (type == "folder") + { + item.type = CollectionItemType::Folder; + const QJsonArray children = obj["children"].toArray(); + item.children.reserve(children.size()); + for (const auto &c : children) + { + bool childOk = false; + CollectionItem child = fromJson(c.toObject(), &childOk); + if (childOk) + item.children.append(child); + } + } + else if (type == "request") + { + item.type = CollectionItemType::Request; + item.requestJson = obj["request"].toObject(); + } + else + { + // Unknown type -> treat as request if it has a "request" object. + if (obj.contains("request")) + { + item.type = CollectionItemType::Request; + item.requestJson = obj["request"].toObject(); + } + else if (ok) + { + *ok = false; + return item; + } + } + + if (ok) + *ok = true; + return item; + } + + bool Collection::load(const QString &path) + { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return false; + + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (doc.isNull() || !doc.isObject()) + return false; + + const QJsonObject root = doc.object(); + m_name = root["name"].toString(); + + const QJsonArray items = root["items"].toArray(); + m_root.children.clear(); + m_root.children.reserve(items.size()); + for (const auto &it : items) + { + bool itemOk = false; + CollectionItem child = fromJson(it.toObject(), &itemOk); + if (itemOk) + m_root.children.append(child); + } + return true; + } + + bool Collection::save(const QString &path) const + { + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + + QJsonObject root; + root["name"] = m_name; + QJsonArray items; + for (const auto &child : m_root.children) + items.append(toJson(child)); + root["items"] = items; + + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return true; + } + + QString Collection::addFolder(const QString &parentId, const QString &name) + { + CollectionItem *parent = parentId.isEmpty() ? &m_root : findById(parentId); + if (!parent) + return QString(); + + CollectionItem folder; + folder.type = CollectionItemType::Folder; + folder.id = genId(); + folder.name = name; + parent->children.append(folder); + return folder.id; + } + + QString Collection::addRequest(const QString &parentId, const QString &name, + const QJsonObject &requestJson) + { + CollectionItem *parent = parentId.isEmpty() ? &m_root : findById(parentId); + if (!parent) + return QString(); + + CollectionItem request; + request.type = CollectionItemType::Request; + request.id = genId(); + request.name = name; + request.requestJson = requestJson; + parent->children.append(request); + return request.id; + } + + void Collection::removeItem(const QString &id) + { + removeFrom(m_root, id); + } + + bool Collection::removeFrom(CollectionItem &parent, const QString &id) + { + for (int i = 0; i < parent.children.size(); ++i) + { + CollectionItem &child = parent.children[i]; + if (child.id == id) + { + parent.children.removeAt(i); + return true; + } + if (removeFrom(child, id)) + return true; + } + return false; + } + + CollectionItem *Collection::findById(const QString &id) + { + return findById(m_root, id); + } + + const CollectionItem *Collection::findById(const QString &id) const + { + return findById(m_root, id); + } + + CollectionItem *Collection::findById(CollectionItem &node, const QString &id) + { + if (node.id == id) + return &node; + for (auto &child : node.children) + { + if (CollectionItem *found = findById(child, id)) + return found; + } + return nullptr; + } + + const CollectionItem *Collection::findById(const CollectionItem &node, const QString &id) const + { + if (node.id == id) + return &node; + for (const auto &child : node.children) + { + if (const CollectionItem *found = findById(child, id)) + return found; + } + return nullptr; + } +} diff --git a/source/environment.cpp b/source/environment.cpp new file mode 100644 index 0000000..b2b2f0f --- /dev/null +++ b/source/environment.cpp @@ -0,0 +1,111 @@ +/* +@Brief: Qt multi-threaded network request module – environment variable substitution + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#include "environment.h" + +#include + +namespace QtNetworkRequest +{ + QString substituteEnv(const QString &text, const QMap &vars) + { + if (vars.isEmpty() || text.isEmpty()) + return text; + + // Match {{key}} where key is [\w.-]+ (no inner whitespace). Unknown keys + // are left intact by returning the original captured text (Postman-style). + // Implemented with a globalMatch iterator (instead of the Qt5.15-only + // replace(QRegularExpression, lambda) overload) for portability. + static const QRegularExpression re(R"(\{\{(\w[\w.-]*)\}\})"); + QString result; + result.reserve(text.size()); + int lastPos = 0; + QRegularExpressionMatchIterator it = re.globalMatch(text); + while (it.hasNext()) + { + const QRegularExpressionMatch m = it.next(); + const int start = m.capturedStart(); + const int end = m.capturedEnd(); + if (start > lastPos) + result.append(text.mid(lastPos, start - lastPos)); + const QString key = m.captured(1); + const auto vit = vars.constFind(key); + result.append((vit != vars.constEnd()) ? vit.value() : m.captured(0)); + lastPos = end; + } + if (lastPos < text.size()) + result.append(text.mid(lastPos)); + return result; + } + + void applyEnvironment(RequestContext &ctx, const QMap &vars) + { + if (vars.isEmpty()) + return; + + ctx.url = substituteEnv(ctx.url, vars); + + for (auto it = ctx.headers.begin(); it != ctx.headers.end(); ++it) + it.value() = substituteEnv(QString::fromUtf8(it.value()), vars).toUtf8(); + + ctx.body = substituteEnv(ctx.body, vars); + + for (auto it = ctx.queryParams.begin(); it != ctx.queryParams.end(); ++it) + it.value() = substituteEnv(it.value(), vars); + + // (R3) AuthConfig fields live outside url/headers/body/queryParams, so a + // tokenUrl like "{{host}}/oauth/token" or an apiKey value "{{apiKey}}" + // would otherwise be sent verbatim. Substitute the relevant fields. + AuthConfig &a = ctx.authConfig; + switch (a.type) + { + case AuthType::Basic: + a.username = substituteEnv(a.username, vars); + a.password = substituteEnv(a.password, vars); + break; + case AuthType::Bearer: + a.token = substituteEnv(a.token, vars); + break; + case AuthType::ApiKey: + a.apiKey = substituteEnv(a.apiKey, vars); + a.apiValue = substituteEnv(a.apiValue, vars); + break; + case AuthType::OAuth2: + a.oauth2Config.tokenUrl = substituteEnv(a.oauth2Config.tokenUrl, vars); + a.oauth2Config.clientId = substituteEnv(a.oauth2Config.clientId, vars); + a.oauth2Config.clientSecret = substituteEnv(a.oauth2Config.clientSecret, vars); + a.oauth2Config.scopes = substituteEnv(a.oauth2Config.scopes, vars); + a.oauth2Config.username = substituteEnv(a.oauth2Config.username, vars); + a.oauth2Config.password = substituteEnv(a.oauth2Config.password, vars); + a.oauth2Config.refreshToken = substituteEnv(a.oauth2Config.refreshToken, vars); + break; + default: + break; + } + + // binaryBody intentionally NOT mutated. + } +} diff --git a/source/oauth2tokencache.cpp b/source/oauth2tokencache.cpp new file mode 100644 index 0000000..ae22401 --- /dev/null +++ b/source/oauth2tokencache.cpp @@ -0,0 +1,68 @@ +/* +@Brief: Qt multi-threaded network request module – OAuth2 token cache + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#include "oauth2tokencache.h" + +#include + +namespace QtNetworkRequest +{ + Q_GLOBAL_STATIC(OAuth2TokenCache, g_oauth2TokenCache) + + OAuth2TokenCache &OAuth2TokenCache::instance() + { + return *g_oauth2TokenCache; + } + + QString OAuth2TokenCache::makeKey(const QString &tokenUrl, const QString &clientId, + const QString &grant, const QString &scopes) + { + return tokenUrl + QLatin1Char('|') + clientId + QLatin1Char('|') + + grant + QLatin1Char('|') + scopes; + } + + bool OAuth2TokenCache::find(const QString &key, OAuth2TokenEntry &out) const + { + QMutexLocker locker(&m_mutex); + auto it = m_entries.constFind(key); + if (it == m_entries.constEnd()) + return false; + out = it.value(); + return true; + } + + void OAuth2TokenCache::store(const QString &key, const OAuth2TokenEntry &entry) + { + QMutexLocker locker(&m_mutex); + m_entries.insert(key, entry); + } + + void OAuth2TokenCache::clear() + { + QMutexLocker locker(&m_mutex); + m_entries.clear(); + } +} diff --git a/source/postmanconverter.cpp b/source/postmanconverter.cpp new file mode 100644 index 0000000..171f210 --- /dev/null +++ b/source/postmanconverter.cpp @@ -0,0 +1,205 @@ +/* +@Brief: Qt multi-threaded network request module – Postman v2.1 converter + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#include "postmanconverter.h" + +#include +#include + +namespace QtNetworkRequest +{ + QJsonObject PostmanConverter::requestToPostman(const QJsonObject &requestJson) + { + QJsonObject req; + req["method"] = requestJson["method"].toString("GET"); + + QJsonObject url; + url["raw"] = requestJson["url"].toString(); + req["url"] = url; + + QJsonArray headers; + for (const auto &h : requestJson["headers"].toArray()) + { + const QJsonObject ho = h.toObject(); + QJsonObject oh; + oh["key"] = ho["key"]; + oh["value"] = ho["value"]; + headers.append(oh); + } + if (!headers.isEmpty()) + req["header"] = headers; + + const QString body = requestJson["body"].toString(); + if (!body.isEmpty()) + { + QJsonObject b; + b["mode"] = "raw"; + b["raw"] = body; + req["body"] = b; + } + + // (R2) round-trip auth so authenticated requests survive import/export. + if (requestJson.contains("auth")) + req["auth"] = requestJson["auth"]; + + return req; + } + + QJsonObject PostmanConverter::postmanToRequestJson(const QJsonObject &requestObj) + { + QJsonObject out; + out["method"] = requestObj["method"].toString("GET"); + + const QJsonValue urlv = requestObj["url"]; + if (urlv.isObject()) + out["url"] = urlv.toObject()["raw"].toString(); + else + out["url"] = urlv.toString(); + + QJsonArray headers; + const QJsonValue hv = requestObj["header"]; + QJsonArray ha = hv.isArray() ? hv.toArray() + : (hv.isObject() ? QJsonArray{ hv.toObject() } : QJsonArray{}); + for (const auto &h : ha) + { + const QJsonObject ho = h.toObject(); + QJsonObject oh; + oh["key"] = ho["key"]; + oh["value"] = ho["value"]; + headers.append(oh); + } + out["headers"] = headers; + + const QJsonValue bv = requestObj["body"]; + if (bv.isObject()) + { + const QString rawBody = bv.toObject()["raw"].toString(); + if (!rawBody.isEmpty()) + out["body"] = rawBody; + } + + // (R2) restore auth if present. + if (requestObj.contains("auth")) + out["auth"] = requestObj["auth"]; + + return out; + } + + QJsonObject PostmanConverter::itemToPostman(const CollectionItem &item) + { + QJsonObject obj; + obj["name"] = item.name; + + if (item.type == CollectionItemType::Folder) + { + QJsonArray children; + for (const auto &child : item.children) + children.append(itemToPostman(child)); + obj["item"] = children; + } + else + { + obj["request"] = requestToPostman(item.requestJson); + } + return obj; + } + + CollectionItem PostmanConverter::postmanToItem(const QJsonObject &obj, bool &ok) + { + CollectionItem item; + item.id = obj["id"].toString(); + if (item.id.isEmpty()) + item.id = Collection::genId(); // Postman items may lack a stable id + item.name = obj["name"].toString(); + + if (obj.contains("item")) // folder + { + item.type = CollectionItemType::Folder; + const QJsonArray children = obj["item"].toArray(); + item.children.reserve(children.size()); + for (const auto &c : children) + { + bool childOk = false; + CollectionItem child = postmanToItem(c.toObject(), childOk); + if (childOk) + item.children.append(child); + } + } + else if (obj.contains("request")) // request + { + item.type = CollectionItemType::Request; + item.requestJson = postmanToRequestJson(obj["request"].toObject()); + } + else + { + ok = false; + return item; + } + + ok = true; + return item; + } + + QJsonObject PostmanConverter::toPostmanV21(const Collection &c) + { + QJsonObject info; + info["name"] = c.name(); + info["schema"] = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"; + + QJsonArray items; + for (const auto &child : c.root().children) + items.append(itemToPostman(child)); + + QJsonObject doc; + doc["info"] = info; + doc["item"] = items; + return doc; + } + + Collection PostmanConverter::fromPostmanV21(const QJsonObject &doc, bool *ok) + { + Collection c; + if (doc.contains("info")) + c.setName(doc["info"].toObject()["name"].toString()); + + const QJsonArray items = doc["item"].toArray(); + bool allOk = true; + c.root().children.reserve(items.size()); + for (const auto &it : items) + { + bool itemOk = false; + CollectionItem child = postmanToItem(it.toObject(), itemOk); + if (itemOk) + c.root().children.append(child); + else + allOk = false; + } + + if (ok) + *ok = allOk; + return c; + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9070836..4db12ea 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -49,6 +49,7 @@ add_executable(BuilderTests main_requestbuilder.cpp test_requestbuilder.cpp test_bodytype.cpp + test_environment.cpp httptestserver.cpp ) @@ -74,6 +75,37 @@ endif() add_test(NAME BuilderTests COMMAND BuilderTests) set_tests_properties(BuilderTests PROPERTIES TIMEOUT 60) +# ══════════════════════════════════════════════════════════════════════════════ +# CollectionTests — Collection model + Postman v2.1 converter (pure, no network) +# ══════════════════════════════════════════════════════════════════════════════ +add_executable(CollectionTests + main_collection.cpp + test_collection.cpp + test_postman.cpp +) + +target_link_libraries(CollectionTests + QNetworkRequest + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Test +) + +target_include_directories(CollectionTests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include +) + +set_output_directories(CollectionTests) + +if(WIN32) + copy_openssl_dlls(CollectionTests "${OPENSSL_BIN_DIR}") + copy_library_artifact(CollectionTests QNetworkRequest) +endif() + +add_test(NAME CollectionTests COMMAND CollectionTests) +set_tests_properties(CollectionTests PROPERTIES TIMEOUT 60) + # ══════════════════════════════════════════════════════════════════════════════ # AuthTests — authentication system integration tests # ══════════════════════════════════════════════════════════════════════════════ diff --git a/test/main_collection.cpp b/test/main_collection.cpp new file mode 100644 index 0000000..3ca7709 --- /dev/null +++ b/test/main_collection.cpp @@ -0,0 +1,21 @@ +#include +#include +#include "test_collection.h" +#include "test_postman.h" +#include "networkrequestmanager.h" + +// Entry point for the pure (no-network) Collection model + Postman v2.1 converter tests. +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QtNetworkRequest::NetworkRequestManager::initialize(); + + int status = 0; + CollectionTests collectionTests; + status |= QTest::qExec(&collectionTests, argc, argv); + PostmanTests postmanTests; + status |= QTest::qExec(&postmanTests, argc, argv); + + QtNetworkRequest::NetworkRequestManager::unInitialize(); + return status; +} diff --git a/test/main_requestbuilder.cpp b/test/main_requestbuilder.cpp index 71ba404..624cdae 100644 --- a/test/main_requestbuilder.cpp +++ b/test/main_requestbuilder.cpp @@ -2,6 +2,7 @@ #include #include "test_requestbuilder.h" #include "test_bodytype.h" +#include "test_environment.h" #include "networkrequestmanager.h" // Dedicated entry point for the pure (no-network) RequestContextBuilder unit @@ -17,6 +18,8 @@ int main(int argc, char *argv[]) status |= QTest::qExec(&testRequestBuilder, argc, argv); BodyTypeTests bodyTypeTests; status |= QTest::qExec(&bodyTypeTests, argc, argv); + EnvironmentTests environmentTests; + status |= QTest::qExec(&environmentTests, argc, argv); QtNetworkRequest::NetworkRequestManager::unInitialize(); return status; diff --git a/test/test_collection.cpp b/test/test_collection.cpp new file mode 100644 index 0000000..6f3be99 --- /dev/null +++ b/test/test_collection.cpp @@ -0,0 +1,85 @@ +#include "test_collection.h" +#include "collectionmodel.h" + +#include +#include +#include + +using namespace QtNetworkRequest; + +namespace +{ +QJsonObject makeRequestJson() +{ + QJsonObject req; + req["method"] = "POST"; + req["url"] = "http://example.com/api"; + req["body"] = "{\"k\":\"v\"}"; + QJsonArray headers; + QJsonObject h; + h["key"] = "Content-Type"; + h["value"] = "application/json"; + headers.append(h); + req["headers"] = headers; + return req; +} +} + +void CollectionTests::testAddFolderAndRequest() +{ + Collection c("My Collection"); + const QString fid = c.addFolder("", "Folder1"); + QVERIFY(!fid.isEmpty()); + const QString rid = c.addRequest(fid, "Req1", makeRequestJson()); + QVERIFY(!rid.isEmpty()); + + CollectionItem *folder = c.findById(fid); + QVERIFY(folder); + QCOMPARE(folder->type, CollectionItemType::Folder); + QCOMPARE(folder->name, QString("Folder1")); + + CollectionItem *req = c.findById(rid); + QVERIFY(req); + QCOMPARE(req->type, CollectionItemType::Request); + QCOMPARE(req->name, QString("Req1")); + QCOMPARE(req->requestJson["method"].toString(), QString("POST")); +} + +void CollectionTests::testRemoveNode() +{ + Collection c("C"); + const QString fid = c.addFolder("", "F"); + const QString rid = c.addRequest(fid, "R", makeRequestJson()); + QVERIFY(c.findById(rid)); + + c.removeItem(rid); + QVERIFY(!c.findById(rid)); + QVERIFY(c.findById(fid)); // sibling folder survives + + c.removeItem(fid); + QVERIFY(!c.findById(fid)); +} + +void CollectionTests::testSerializeDeserialize() +{ + Collection c("Persisted"); + const QString fid = c.addFolder("", "F1"); + c.addRequest(fid, "R1", makeRequestJson()); + + const QString path = QDir::temp().absoluteFilePath("qtmtnetwork_collection_test.json"); + QVERIFY(c.save(path)); + + Collection c2; + QVERIFY(c2.load(path)); + QCOMPARE(c2.name(), QString("Persisted")); + QCOMPARE(c2.root().children.size(), 1); + + const CollectionItem *folder = c2.findById(fid); + QVERIFY(folder); + QCOMPARE(folder->name, QString("F1")); + QCOMPARE(folder->children.size(), 1); + QCOMPARE(folder->children.first().requestJson["method"].toString(), QString("POST")); + QCOMPARE(folder->children.first().requestJson["url"].toString(), QString("http://example.com/api")); + + QFile::remove(path); +} diff --git a/test/test_collection.h b/test/test_collection.h new file mode 100644 index 0000000..579a8de --- /dev/null +++ b/test/test_collection.h @@ -0,0 +1,18 @@ +#ifndef TEST_COLLECTION_H +#define TEST_COLLECTION_H + +#include +#include + +// Pure (no-network) unit tests for the Collection tree model. +class CollectionTests : public QObject +{ + Q_OBJECT + +private slots: + void testAddFolderAndRequest(); + void testRemoveNode(); + void testSerializeDeserialize(); +}; + +#endif // TEST_COLLECTION_H diff --git a/test/test_environment.cpp b/test/test_environment.cpp new file mode 100644 index 0000000..8dfc68b --- /dev/null +++ b/test/test_environment.cpp @@ -0,0 +1,132 @@ +#include "test_environment.h" + +#include "requestcontext.h" +#include "authconfig.h" +#include "environment.h" + +using namespace QtNetworkRequest; + +void EnvironmentTests::testSubstituteBasic() +{ + QMap vars; + vars["host"] = "http://example.com"; + QCOMPARE(substituteEnv("{{host}}/api", vars), QString("http://example.com/api")); + // multiple occurrences + QCOMPARE(substituteEnv("{{host}}/a/{{host}}/b", vars), + QString("http://example.com/a/http://example.com/b")); +} + +void EnvironmentTests::testSubstituteMissingLeftIntact() +{ + QMap vars; + vars["host"] = "http://example.com"; + // unknown key is left verbatim (Postman-style) + QCOMPARE(substituteEnv("{{missing}}/api", vars), QString("{{missing}}/api")); + // no vars at all -> unchanged + QCOMPARE(substituteEnv("{{host}}/api", QMap()), QString("{{host}}/api")); + // unbalanced braces -> left intact + QCOMPARE(substituteEnv("{{host", vars), QString("{{host")); +} + +void EnvironmentTests::testSubstituteUrlAndQueryParams() +{ + QMap vars; + vars["host"] = "http://h"; + vars["q"] = "search"; + + auto ctx = RequestContextBuilder() + .url("{{host}}/x") + .queryParam("q", "{{q}}") + .build(); + + applyEnvironment(*ctx, vars); + QCOMPARE(ctx->url, QString("http://h/x")); + QCOMPARE(ctx->queryParams.value("q"), QString("search")); +} + +void EnvironmentTests::testSubstituteHeaders() +{ + QMap vars; + vars["tok"] = "abc"; + + auto ctx = RequestContextBuilder() + .header("X-Token", "{{tok}}") + .build(); + + applyEnvironment(*ctx, vars); + QCOMPARE(QString::fromUtf8(ctx->headers.value("X-Token")), QString("abc")); +} + +void EnvironmentTests::testSubstituteBody() +{ + QMap vars; + vars["host"] = "http://h"; + + auto ctx = RequestContextBuilder() + .bodyJson("{\"url\":\"{{host}}\"}") + .build(); + + applyEnvironment(*ctx, vars); + QCOMPARE(ctx->body, QString("{\"url\":\"http://h\"}")); +} + +void EnvironmentTests::testSubstituteSkipsBinary() +{ + QByteArray bin; + bin.append('A'); + bin.append('B'); + bin.append('\0'); // embedded NUL must survive untouched + bin.append('C'); + bin.append('D'); + + auto ctx = RequestContextBuilder().bodyBinary(bin).build(); + + QMap vars; + vars["x"] = "y"; + applyEnvironment(*ctx, vars); + + QCOMPARE(ctx->binaryBody, bin); // never mutated +} + +void EnvironmentTests::testSubstituteAuthFields() +{ + QMap vars; + vars["host"] = "http://h"; + vars["cid"] = "client-123"; + + // OAuth2 tokenUrl / clientId carry {{var}} (R3) + AuthConfig::OAuth2Config o; + o.grant = OAuth2GrantType::ClientCredentials; + o.tokenUrl = "{{host}}/oauth/token"; + o.clientId = "{{cid}}"; + o.clientSecret = "secret"; + + auto ctx = RequestContextBuilder().authConfig(AuthConfig::oauth2(o)).build(); + applyEnvironment(*ctx, vars); + + QCOMPARE(ctx->authConfig.oauth2Config.tokenUrl, QString("http://h/oauth/token")); + QCOMPARE(ctx->authConfig.oauth2Config.clientId, QString("client-123")); + QCOMPARE(ctx->authConfig.oauth2Config.clientSecret, QString("secret")); + + // Bearer token also substituted + auto ctx2 = RequestContextBuilder().authConfig(AuthConfig::bearer("{{tok}}")).build(); + QMap vars2; + vars2["tok"] = "secret-token"; + applyEnvironment(*ctx2, vars2); + QCOMPARE(ctx2->authConfig.token, QString("secret-token")); +} + +void EnvironmentTests::testRequestContextBuilderEnvironment() +{ + QMap vars; + vars["host"] = "http://t"; + + auto ctx = RequestContextBuilder() + .url("{{host}}/x") + .environment(vars) + .build(); + + // The pipeline applies ctx->environment; emulate that here. + applyEnvironment(*ctx, ctx->environment); + QCOMPARE(ctx->url, QString("http://t/x")); +} diff --git a/test/test_environment.h b/test/test_environment.h new file mode 100644 index 0000000..24807a1 --- /dev/null +++ b/test/test_environment.h @@ -0,0 +1,23 @@ +#ifndef TEST_ENVIRONMENT_H +#define TEST_ENVIRONMENT_H + +#include +#include + +// Pure (no-network) unit tests for {{var}} environment substitution. +class EnvironmentTests : public QObject +{ + Q_OBJECT + +private slots: + void testSubstituteBasic(); + void testSubstituteMissingLeftIntact(); + void testSubstituteUrlAndQueryParams(); + void testSubstituteHeaders(); + void testSubstituteBody(); + void testSubstituteSkipsBinary(); + void testSubstituteAuthFields(); + void testRequestContextBuilderEnvironment(); +}; + +#endif // TEST_ENVIRONMENT_H diff --git a/test/test_postman.cpp b/test/test_postman.cpp new file mode 100644 index 0000000..8fb374d --- /dev/null +++ b/test/test_postman.cpp @@ -0,0 +1,198 @@ +#include "test_postman.h" +#include "collectionmodel.h" +#include "postmanconverter.h" + +#include + +using namespace QtNetworkRequest; + +namespace +{ +// A representative single-request payload. Includes an auth block so the +// Postman round-trip also exercises R2 (auth must survive export/import). +QJsonObject makeRequestJson() +{ + QJsonObject req; + req["method"] = "POST"; + req["url"] = "http://example.com/api"; + req["body"] = "{\"k\":\"v\"}"; + QJsonArray headers; + QJsonObject h; + h["key"] = "Content-Type"; + h["value"] = "application/json"; + headers.append(h); + req["headers"] = headers; + + QJsonObject auth; + auth["type"] = "bearer"; + QJsonObject bearer; + bearer["token"] = "abc123"; + auth["bearer"] = bearer; + req["auth"] = auth; + return req; +} + +// Recursive structural comparison. Internal ids are intentionally dropped on +// Postman export (Postman assigns its own), so we compare type / name / +// request fields / auth rather than ids. +bool compareItems(const CollectionItem &a, const CollectionItem &b) +{ + if (a.type != b.type) + return false; + if (a.name != b.name) + return false; + if (a.type == CollectionItemType::Request) + { + const QJsonObject ra = a.requestJson; + const QJsonObject rb = b.requestJson; + if (ra["method"].toString() != rb["method"].toString()) + return false; + if (ra["url"].toString() != rb["url"].toString()) + return false; + if (ra["body"].toString() != rb["body"].toString()) + return false; + if (ra["auth"] != rb["auth"]) // R2: auth round-trips + return false; + const QJsonArray ha = ra["headers"].toArray(); + const QJsonArray hb = rb["headers"].toArray(); + if (ha.size() != hb.size()) + return false; + for (int i = 0; i < ha.size(); ++i) + { + if (ha[i].toObject()["key"].toString() != hb[i].toObject()["key"].toString()) + return false; + if (ha[i].toObject()["value"].toString() != hb[i].toObject()["value"].toString()) + return false; + } + } + if (a.children.size() != b.children.size()) + return false; + for (int i = 0; i < a.children.size(); ++i) + if (!compareItems(a.children[i], b.children[i])) + return false; + return true; +} +} + +void PostmanTests::test_postmanExportRoundTrip() +{ + Collection c("ExportTest"); + const QString fid = c.addFolder("", "Folder1"); + c.addRequest(fid, "Req1", makeRequestJson()); + const QString fid2 = c.addFolder(fid, "SubFolder"); + c.addRequest(fid2, "Req2", makeRequestJson()); + + const QJsonObject pm = PostmanConverter::toPostmanV21(c); + QCOMPARE(pm["info"].toObject()["name"].toString(), QString("ExportTest")); + QVERIFY(pm["item"].isArray()); + + bool ok = false; + Collection back = PostmanConverter::fromPostmanV21(pm, &ok); + QVERIFY(ok); + QCOMPARE(back.name(), QString("ExportTest")); + QVERIFY(compareItems(c.root(), back.root())); +} + +void PostmanTests::test_postmanImportBasic() +{ + QJsonObject reqObj; + reqObj["method"] = "POST"; + QJsonObject url; + url["raw"] = "http://example.com/api"; + reqObj["url"] = url; + QJsonArray headers; + QJsonObject h; + h["key"] = "Content-Type"; + h["value"] = "application/json"; + headers.append(h); + reqObj["header"] = headers; + QJsonObject body; + body["mode"] = "raw"; + body["raw"] = "{\"k\":\"v\"}"; + reqObj["body"] = body; + + QJsonObject item; + item["name"] = "BasicReq"; + item["request"] = reqObj; + + QJsonObject info; + info["name"] = "Imported"; + QJsonObject doc; + doc["info"] = info; + QJsonArray items; + items.append(item); + doc["item"] = items; + + bool ok = false; + Collection c = PostmanConverter::fromPostmanV21(doc, &ok); + QVERIFY(ok); + QCOMPARE(c.name(), QString("Imported")); + QCOMPARE(c.root().children.size(), 1); + + const CollectionItem *r = c.findById(c.root().children.first().id); + QVERIFY(r); + QCOMPARE(r->type, CollectionItemType::Request); + QCOMPARE(r->name, QString("BasicReq")); + QCOMPARE(r->requestJson["method"].toString(), QString("POST")); + QCOMPARE(r->requestJson["url"].toString(), QString("http://example.com/api")); + QCOMPARE(r->requestJson["body"].toString(), QString("{\"k\":\"v\"}")); + QCOMPARE(r->requestJson["headers"].toArray().size(), 1); + QCOMPARE(r->requestJson["headers"].toArray().first().toObject()["key"].toString(), + QString("Content-Type")); + QCOMPARE(r->requestJson["headers"].toArray().first().toObject()["value"].toString(), + QString("application/json")); +} + +void PostmanTests::test_postmanImportNestedFolders() +{ + QJsonObject reqObj; + reqObj["method"] = "GET"; + QJsonObject url; + url["raw"] = "http://x/y"; + reqObj["url"] = url; + QJsonObject req; + req["name"] = "R"; + req["request"] = reqObj; + + QJsonObject f2; + f2["name"] = "F2"; + QJsonArray f2items; + f2items.append(req); + f2["item"] = f2items; + + QJsonObject f1; + f1["name"] = "F1"; + QJsonArray f1items; + f1items.append(f2); + f1["item"] = f1items; + + QJsonObject info; + info["name"] = "N"; + QJsonObject doc; + doc["info"] = info; + QJsonArray items; + items.append(f1); + doc["item"] = items; + + bool ok = false; + Collection c = PostmanConverter::fromPostmanV21(doc, &ok); + QVERIFY(ok); + QCOMPARE(c.root().children.size(), 1); + + const CollectionItem *f1p = c.findById(c.root().children.first().id); + QVERIFY(f1p); + QCOMPARE(f1p->type, CollectionItemType::Folder); + QCOMPARE(f1p->name, QString("F1")); + QCOMPARE(f1p->children.size(), 1); + + const CollectionItem *f2p = c.findById(f1p->children.first().id); + QVERIFY(f2p); + QCOMPARE(f2p->name, QString("F2")); + QCOMPARE(f2p->children.size(), 1); + + const CollectionItem *rp = c.findById(f2p->children.first().id); + QVERIFY(rp); + QCOMPARE(rp->type, CollectionItemType::Request); + QCOMPARE(rp->name, QString("R")); + QCOMPARE(rp->requestJson["url"].toString(), QString("http://x/y")); +} diff --git a/test/test_postman.h b/test/test_postman.h new file mode 100644 index 0000000..05df66d --- /dev/null +++ b/test/test_postman.h @@ -0,0 +1,18 @@ +#ifndef TEST_POSTMAN_H +#define TEST_POSTMAN_H + +#include +#include + +// Pure (no-network) unit tests for the Postman v2.1 converter. +class PostmanTests : public QObject +{ + Q_OBJECT + +private slots: + void test_postmanExportRoundTrip(); + void test_postmanImportBasic(); + void test_postmanImportNestedFolders(); +}; + +#endif // TEST_POSTMAN_H From 01524e6474f0965c3d43a9703109403ac0785c1c Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 17:38:17 +0800 Subject: [PATCH 04/12] M1: environment variable GUI end-to-end Pipeline (B.2): - source/networkrequest.cpp: wire applyEnvironment() into setRequestContext() after m_upContext = move and before m_url = QUrl, so {{var}} placeholders are resolved before the URL/headers/body/query/auth are finalized. EnvironmentStore (B.3): - include/environmentstore.h, source/environmentstore.cpp: multi-environment manager with JSON persistence ({active, environments[{name, variables}]}). NETWORK_EXPORT, no UI dependency. - source/CMakeLists.txt: add environmentstore.cpp + .h GUI (B.4): - networkrequesttool.h: EnvironmentStore m_envStore, QComboBox/PushButton members, onEnvironmentChanged/onManageEnvironments slots, helpers. - networkrequesttool.cpp: - initializeUI(): inject cmb_environment + btn_manage_env into request toolbar - buildRequestContext(): append .environment(m_envStore.activeVariables()) - loadEnvironments(): load from environments.json, create default 'Dev' on first run (host=localhost, port=8080) - onManageEnvironments(): full dialog with QListWidget env picker + QTableWidget variable editor + Add/Remove + JSON persistence UiTests: - test_qtrequester.h/.cpp: test_envDropdownSwitch verifies combo exists, No Environment yields empty map, Dev selection populates activeVariables, buildRequestContext carries the env map. All 7 test suites green. --- include/environmentstore.h | 83 +++++ .../networkrequesttool/networkrequesttool.cpp | 299 ++++++++++++++++++ .../networkrequesttool/networkrequesttool.h | 9 + source/CMakeLists.txt | 2 + source/environmentstore.cpp | 145 +++++++++ source/networkrequest.cpp | 5 + test/test_qtrequester.cpp | 48 +++ test/test_qtrequester.h | 3 + 8 files changed, 594 insertions(+) create mode 100644 include/environmentstore.h create mode 100644 source/environmentstore.cpp diff --git a/include/environmentstore.h b/include/environmentstore.h new file mode 100644 index 0000000..e10ab6c --- /dev/null +++ b/include/environmentstore.h @@ -0,0 +1,83 @@ +/* +@Brief: Qt multi-threaded network request module – named environment store + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#pragma once + +#include +#include +#include +#include "networkrequestglobal.h" + +namespace QtNetworkRequest +{ + /// Manages multiple named environments (dev/staging/prod) with key-value + /// variable maps. One JSON file on disk: { "active": "dev", "environments": [...] }. + /// The active environment's variables are feed into RequestContext::environment + /// so the library's substitution pipeline replaces {{host}}, {{apiKey}}, etc. + class NETWORK_EXPORT EnvironmentStore + { + public: + EnvironmentStore() = default; + + /// Load environments from the JSON file at the given path. + /// If the file doesn't exist the store is left empty (no error). + bool load(const QString &path); + + /// Persist all environments + active name to disk. + bool save(const QString &path) const; + + /// Names of all registered environments (order from JSON). + QStringList environmentNames() const; + + /// Which environment is currently active. + /// Returns an empty string when "No Environment" is selected. + const QString &activeName() const { return m_activeName; } + + /// Switch the active environment. An empty name means "No Environment" + /// (activeVariables() returns an empty map). + void setActiveName(const QString &name); + + /// Return the variables of the active environment (or empty map). + QMap activeVariables() const; + + /// Insert or update an environment with the given name and variables. + void upsert(const QString &name, const QMap &vars); + + /// Remove an environment by name. If it was the active one the active + /// name is cleared. + void remove(const QString &name); + + private: + struct EnvEntry + { + QString name; + QMap variables; + }; + + QString m_activeName; + QList m_environments; + }; +} diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index 6bdccf2..68070ac 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -89,6 +92,7 @@ void NetworkRequestTool::initialize() initializeUI(); initializeConnections(); setupDefaultValues(); + loadEnvironments(); } void NetworkRequestTool::unInitialize() @@ -152,6 +156,27 @@ void NetworkRequestTool::initializeUI() table->horizontalHeader()->setHighlightSections(false); table->verticalHeader()->setHighlightSections(false); } + + // --- Environment selector (injected into request toolbar) --- + auto *toolbarLayout = qobject_cast( + ui.frame_request_toolbar->layout()); + if (toolbarLayout) + { + // Spacer between Settings and the env section + toolbarLayout->addStretch(); + + m_cmbEnvironment = new QComboBox(); + m_cmbEnvironment->setObjectName("cmb_environment"); + m_cmbEnvironment->setMinimumWidth(120); + m_cmbEnvironment->setToolTip("Active environment"); + toolbarLayout->addWidget(m_cmbEnvironment); + + m_btnManageEnv = new QPushButton("Env"); + m_btnManageEnv->setObjectName("btn_manage_env"); + m_btnManageEnv->setToolTip("Manage environments"); + m_btnManageEnv->setFixedWidth(40); + toolbarLayout->addWidget(m_btnManageEnv); + } } void NetworkRequestTool::initializeConnections() @@ -165,6 +190,13 @@ void NetworkRequestTool::initializeConnections() connect(ui.btn_settings, &QPushButton::clicked, this, &NetworkRequestTool::onSettingsClicked); connect(ui.btn_new_request, &QPushButton::clicked, this, &NetworkRequestTool::onNewRequest); + // Environment + if (m_cmbEnvironment) + connect(m_cmbEnvironment, QOverload::of(&QComboBox::currentIndexChanged), + this, &NetworkRequestTool::onEnvironmentChanged); + if (m_btnManageEnv) + connect(m_btnManageEnv, &QPushButton::clicked, this, &NetworkRequestTool::onManageEnvironments); + // Parameters and request headers connect(ui.btn_add_param, &QPushButton::clicked, this, &NetworkRequestTool::onAddParam); connect(ui.btn_remove_param, &QPushButton::clicked, this, &NetworkRequestTool::onRemoveParam); @@ -704,6 +736,7 @@ std::unique_ptr NetworkRequestTool::buildRequestContext() .type(getRequestType()) .headers(getHeaders()) .queryParams(getQueryParams()) + .environment(m_envStore.activeVariables()) .authConfig(buildAuthConfig()); // Body type -> builder method mapping @@ -1594,6 +1627,272 @@ void NetworkRequestTool::ensureStorageDir() dir.mkpath("."); } +// --- Environment management (M1) --- + +void NetworkRequestTool::loadEnvironments() +{ + ensureStorageDir(); + const QString envPath = storageDir() + "/environments.json"; + if (!m_envStore.load(envPath)) + { + // File doesn't exist yet — start with a default "dev" environment. + QMap vars; + vars["host"] = "localhost"; + vars["port"] = "8080"; + m_envStore.upsert("Dev", vars); + m_envStore.setActiveName(""); + m_envStore.save(envPath); + } + populateEnvironmentCombo(); +} + +void NetworkRequestTool::saveEnvironments() +{ + ensureStorageDir(); + m_envStore.save(storageDir() + "/environments.json"); +} + +void NetworkRequestTool::populateEnvironmentCombo() +{ + if (!m_cmbEnvironment) + return; + + // Block signals during rebuild to avoid triggering onEnvironmentChanged + m_cmbEnvironment->blockSignals(true); + m_cmbEnvironment->clear(); + m_cmbEnvironment->addItem("No Environment", QString()); + + const QStringList names = m_envStore.environmentNames(); + for (const QString &name : names) + m_cmbEnvironment->addItem(name, name); + + // Restore selection + const QString active = m_envStore.activeName(); + const int idx = active.isEmpty() ? 0 : m_cmbEnvironment->findData(active); + m_cmbEnvironment->setCurrentIndex(idx >= 0 ? idx : 0); + m_cmbEnvironment->blockSignals(false); +} + +void NetworkRequestTool::onEnvironmentChanged(int /*index*/) +{ + if (!m_cmbEnvironment) + return; + + const QString name = m_cmbEnvironment->currentData().toString(); + m_envStore.setActiveName(name); +} + +void NetworkRequestTool::onManageEnvironments() +{ + // --- Build a simple Manage Environments dialog --- + QDialog dlg(this); + dlg.setWindowTitle("Manage Environments"); + dlg.resize(500, 400); + + auto *mainLayout = new QVBoxLayout(&dlg); + + // Environment list + auto *listWidget = new QListWidget(&dlg); + const QStringList names = m_envStore.environmentNames(); + for (const QString &name : names) + listWidget->addItem(name); + mainLayout->addWidget(listWidget); + + // Add / Remove / Rename buttons + auto *btnLayout = new QHBoxLayout(); + auto *btnAdd = new QPushButton("Add", &dlg); + auto *btnRemove = new QPushButton("Remove", &dlg); + btnLayout->addWidget(btnAdd); + btnLayout->addWidget(btnRemove); + btnLayout->addStretch(); + mainLayout->addLayout(btnLayout); + + // Variable editor table + auto *varTable = new QTableWidget(0, 2, &dlg); + varTable->setHorizontalHeaderLabels({"Variable", "Value"}); + varTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + mainLayout->addWidget(varTable); + + // Add/Remove row buttons for the variable table + auto *varBtnLayout = new QHBoxLayout(); + auto *btnAddVar = new QPushButton("+ Variable", &dlg); + auto *btnRemoveVar = new QPushButton("- Variable", &dlg); + varBtnLayout->addWidget(btnAddVar); + varBtnLayout->addWidget(btnRemoveVar); + varBtnLayout->addStretch(); + mainLayout->addLayout(varBtnLayout); + + // Dialog buttons + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); + mainLayout->addWidget(buttonBox); + + // --- Sync: load variables for the currently selected environment --- + auto loadVarsForEnv = [&](const QString &envName) { + varTable->setRowCount(0); + if (envName.isEmpty()) + return; + for (const auto &entry : m_envStore.activeVariables()) + ; // not used — we need per-env access + // Workaround: rebuild from scratch using the names list + // Actually we need to iterate m_envStore per the selected name. + // For now, re-populate from known data using a simple approach. + }; + + // Since EnvironmentStore doesn't expose per-env variables publicly, + // we use upsert-then-read pattern. For now, implement a simpler approach + // that stores a working copy. + QMap> envVars; + // Initialize working copies from the store by iterating names + { + // We need per-env variable access. Add a simple member to hold them. + // Rebuild by selective load/save. + } + + // --- Simpler approach: embed the working state in lambdas --- + // Pre-populate all env data from the store + // Since we can't iterate envs easily from the public API, rebuild from the + // combination of names + known data. + // Use a fresh store copy approach — load from file again. + EnvironmentStore workStore; + const QString envPath = storageDir() + "/environments.json"; + workStore.load(envPath); + + // Current selected env in the dialog + QString selectedEnv; + + // Populate variable table when an environment is selected + QObject::connect(listWidget, &QListWidget::currentItemChanged, + [&](QListWidgetItem *current, QListWidgetItem * /*prev*/) { + if (!current) + return; + selectedEnv = current->text(); + varTable->setRowCount(0); + // We need per-env access. Since we loaded workStore, re-derive: + // The simplest workaround: track variables manually + }); + + // Since per-env variable access requires iterating environments, + // and the public API doesn't expose it, let me add a helper or refactor. + // For M1, use a pragmatic approach: manually map env name -> vars. + QMap> varsMap; + { + // We need to get all envs. Load them from the file and map. + QFile file(envPath); + if (file.open(QIODevice::ReadOnly)) + { + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + QJsonArray envs = doc.object()["environments"].toArray(); + for (const auto &ev : envs) + { + QJsonObject eo = ev.toObject(); + QString ename = eo["name"].toString(); + QMap vmap; + QJsonObject vobj = eo["variables"].toObject(); + for (auto it = vobj.begin(); it != vobj.end(); ++it) + vmap[it.key()] = it.value().toString(); + varsMap[ename] = vmap; + } + } + } + + // Re-connect with proper vars access + QObject::disconnect(listWidget, &QListWidget::currentItemChanged, nullptr, nullptr); + QObject::connect(listWidget, &QListWidget::currentItemChanged, + [&](QListWidgetItem *current, QListWidgetItem * /*prev*/) { + if (!current) + return; + selectedEnv = current->text(); + varTable->setRowCount(0); + const auto &vars = varsMap[selectedEnv]; + for (auto it = vars.begin(); it != vars.end(); ++it) + { + int row = varTable->rowCount(); + varTable->insertRow(row); + varTable->setItem(row, 0, new QTableWidgetItem(it.key())); + varTable->setItem(row, 1, new QTableWidgetItem(it.value())); + } + }); + + // Initial selection + if (listWidget->count() > 0) + listWidget->setCurrentRow(0); + + // Add environment + QObject::connect(btnAdd, &QPushButton::clicked, [&]() { + bool ok = false; + QString name = QInputDialog::getText(&dlg, "Add Environment", "Name:", QLineEdit::Normal, "", &ok); + if (ok && !name.isEmpty()) + { + listWidget->addItem(name); + varsMap[name] = {}; + listWidget->setCurrentRow(listWidget->count() - 1); + } + }); + + // Remove environment + QObject::connect(btnRemove, &QPushButton::clicked, [&]() { + int row = listWidget->currentRow(); + if (row < 0) + return; + QString name = listWidget->currentItem()->text(); + varsMap.remove(name); + delete listWidget->takeItem(row); + if (listWidget->count() > 0) + listWidget->setCurrentRow(0); + varTable->setRowCount(0); + }); + + // Add variable row + QObject::connect(btnAddVar, &QPushButton::clicked, [&]() { + int row = varTable->rowCount(); + varTable->insertRow(row); + varTable->setItem(row, 0, new QTableWidgetItem("")); + varTable->setItem(row, 1, new QTableWidgetItem("")); + }); + + // Remove variable row + QObject::connect(btnRemoveVar, &QPushButton::clicked, [&]() { + int row = varTable->currentRow(); + if (row >= 0) + varTable->removeRow(row); + }); + + // Dialog accepted → persist + QObject::connect(buttonBox, &QDialogButtonBox::accepted, [&]() { + // Save current table state to varsMap + if (!selectedEnv.isEmpty()) + { + QMap vars; + for (int i = 0; i < varTable->rowCount(); ++i) + { + QTableWidgetItem *keyItem = varTable->item(i, 0); + QTableWidgetItem *valItem = varTable->item(i, 1); + QString key = keyItem ? keyItem->text().trimmed() : QString(); + if (!key.isEmpty()) + vars[key] = valItem ? valItem->text() : QString(); + } + varsMap[selectedEnv] = vars; + } + // Rebuild the store + m_envStore = EnvironmentStore(); + for (auto it = varsMap.begin(); it != varsMap.end(); ++it) + m_envStore.upsert(it.key(), it.value()); + // Preserve active name if still valid + const QString prevActive = m_envStore.activeName(); + if (prevActive.isEmpty() || !varsMap.contains(prevActive)) + m_envStore.setActiveName(""); + else + m_envStore.setActiveName(prevActive); + saveEnvironments(); + populateEnvironmentCombo(); + dlg.accept(); + }); + + QObject::connect(buttonBox, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + + dlg.exec(); +} + void NetworkRequestTool::saveToDisk(const QString &filePath) { QJsonObject obj; diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 220e6a8..f0aab79 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -5,6 +5,7 @@ #include "ui_NetworkRequestTool.h" #include "requestcontext.h" #include "authconfig.h" +#include "environmentstore.h" #include "responseresult.h" #include #include @@ -77,6 +78,8 @@ private slots: void onAddHeader(); void onRemoveHeader(); void onSettingsClicked(); + void onEnvironmentChanged(int index); + void onManageEnvironments(); void onResponse(QSharedPointer rsp); void onHistoryItemClicked(QListWidgetItem *item); void onSearchHistory(const QString &text); @@ -135,6 +138,9 @@ private slots: bool isDefaultHeader(const QString &strHeader); QString storageDir(); void ensureStorageDir(); + void loadEnvironments(); + void saveEnvironments(); + void populateEnvironmentCombo(); private: #ifdef QT_MTNETWORK_UNIT_TEST @@ -152,6 +158,9 @@ private slots: QMap kvPairs; QListWidgetItem *currentHistoryItem; RequestSettings m_settings; + EnvironmentStore m_envStore; + QComboBox *m_cmbEnvironment{nullptr}; + QPushButton *m_btnManageEnv{nullptr}; QLabel *m_labelResponseInfo; QString m_binaryFilePath; }; diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 1e7f829..fccca7c 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(QNetworkRequest SHARED oauth2tokencache.cpp collectionmodel.cpp postmanconverter.cpp + environmentstore.cpp # ── Private headers (AUTOMOC) ──────────────────────────────────────────── memorymappedfile.h @@ -59,6 +60,7 @@ add_library(QNetworkRequest SHARED ${CMAKE_SOURCE_DIR}/include/oauth2tokencache.h ${CMAKE_SOURCE_DIR}/include/collectionmodel.h ${CMAKE_SOURCE_DIR}/include/postmanconverter.h + ${CMAKE_SOURCE_DIR}/include/environmentstore.h ) # ── Compile definitions ────────────────────────────────────────────────────── diff --git a/source/environmentstore.cpp b/source/environmentstore.cpp new file mode 100644 index 0000000..e4aeffb --- /dev/null +++ b/source/environmentstore.cpp @@ -0,0 +1,145 @@ +/* +@Brief: Qt multi-threaded network request module – EnvironmentStore implementation + +MIT License + +Copyright (c) 2025 Lucas Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +#include "environmentstore.h" + +#include +#include +#include +#include + +namespace QtNetworkRequest +{ + bool EnvironmentStore::load(const QString &path) + { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return false; + + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) + return false; + + const QJsonObject root = doc.object(); + m_activeName = root["active"].toString(); + m_environments.clear(); + + const QJsonArray envs = root["environments"].toArray(); + m_environments.reserve(envs.size()); + for (const auto &ev : envs) + { + const QJsonObject eo = ev.toObject(); + EnvEntry entry; + entry.name = eo["name"].toString(); + + const QJsonObject vars = eo["variables"].toObject(); + for (auto it = vars.begin(); it != vars.end(); ++it) + entry.variables.insert(it.key(), it.value().toString()); + + m_environments.append(entry); + } + return true; + } + + bool EnvironmentStore::save(const QString &path) const + { + QJsonObject root; + root["active"] = m_activeName; + + QJsonArray envs; + for (const auto &entry : m_environments) + { + QJsonObject eo; + eo["name"] = entry.name; + + QJsonObject vars; + for (auto it = entry.variables.begin(); it != entry.variables.end(); ++it) + vars.insert(it.key(), it.value()); + eo["variables"] = vars; + + envs.append(eo); + } + root["environments"] = envs; + + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return true; + } + + QStringList EnvironmentStore::environmentNames() const + { + QStringList names; + names.reserve(m_environments.size()); + for (const auto &e : m_environments) + names.append(e.name); + return names; + } + + void EnvironmentStore::setActiveName(const QString &name) + { + m_activeName = name; + } + + QMap EnvironmentStore::activeVariables() const + { + for (const auto &e : m_environments) + { + if (e.name == m_activeName) + return e.variables; + } + return {}; + } + + void EnvironmentStore::upsert(const QString &name, const QMap &vars) + { + for (auto &e : m_environments) + { + if (e.name == name) + { + e.variables = vars; + return; + } + } + m_environments.append({ name, vars }); + } + + void EnvironmentStore::remove(const QString &name) + { + for (int i = 0; i < m_environments.size(); ++i) + { + if (m_environments[i].name == name) + { + m_environments.removeAt(i); + if (m_activeName == name) + m_activeName.clear(); + return; + } + } + } +} diff --git a/source/networkrequest.cpp b/source/networkrequest.cpp index 21c6a0d..31e5853 100644 --- a/source/networkrequest.cpp +++ b/source/networkrequest.cpp @@ -11,6 +11,7 @@ #include "networkrequestmanager.h" #include "networkrequestregistry.h" #include "sharedcookiejar.h" +#include "environment.h" #include "qtcompat.h" using namespace QtNetworkRequest; @@ -602,6 +603,10 @@ void NetworkRequest::setRequestContext(std::unique_ptr context) if (context) { m_upContext = std::move(context); + // (M1) Substitute {{var}} placeholders across url/headers/body/query/auth + // before the QUrl is finalized, so the resolved value is used downstream. + if (!m_upContext->environment.isEmpty()) + applyEnvironment(*m_upContext, m_upContext->environment); m_url = QUrl(m_upContext->url); } } diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index 3ae8823..1477060 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -18,6 +18,7 @@ #include "test_qtrequester.h" #include "networkrequesttool.h" #include "requestcontext.h" +#include "environmentstore.h" #include "responseresult.h" #include "networkrequestmanager.h" #include "networkreply.h" @@ -494,3 +495,50 @@ void TestQtRequester::testXmlHighlighter() QVERIFY2(totalFormats > 0, "XML highlighter produced no character formats"); } +// --------------------------------------------------------------------------- +// M1 Environment dropdown +// --------------------------------------------------------------------------- +void TestQtRequester::test_envDropdownSwitch() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Verify the env combo box exists + auto *cmbEnv = tool.findChild("cmb_environment"); + QVERIFY(cmbEnv); + QVERIFY(cmbEnv->count() >= 1); // At least "No Environment" + + // Verify the first entry is "No Environment" + QCOMPARE(cmbEnv->itemText(0), QString("No Environment")); + QVERIFY(cmbEnv->itemData(0).toString().isEmpty()); + + // The default env file doesn't exist yet, so the tool creates a "Dev" default + QVERIFY(cmbEnv->count() >= 2); // "No Environment" + at least "Dev" + + // Select "No Environment" → activeVariables() should be empty + cmbEnv->setCurrentIndex(0); + auto vars = tool.m_envStore.activeVariables(); + QVERIFY2(vars.isEmpty(), "No Environment should yield empty active variables"); + + // Select "Dev" → should have variables + int devIdx = cmbEnv->findText("Dev"); + if (devIdx >= 0) + { + cmbEnv->setCurrentIndex(devIdx); + QTest::qWait(50); + vars = tool.m_envStore.activeVariables(); + QVERIFY2(!vars.isEmpty(), "Dev environment should have variables"); + QCOMPARE(vars.value("host"), QString("localhost")); + } + + // buildRequestContext should include the env map + auto req = tool.buildRequestContext(); + QVERIFY(req); + if (devIdx >= 0) + { + QVERIFY2(!req->environment.isEmpty(), "RequestContext should carry active env vars"); + QCOMPARE(req->environment.value("host"), QString("localhost")); + } +} + diff --git a/test/test_qtrequester.h b/test/test_qtrequester.h index 5b8431b..4106866 100644 --- a/test/test_qtrequester.h +++ b/test/test_qtrequester.h @@ -35,6 +35,9 @@ private slots: void testUpdateBodyTypeFromContentType(); void testJsonHighlighter(); void testXmlHighlighter(); + + // M1 environment + void test_envDropdownSwitch(); }; #endif From d552f0f676c54050fbb4c484c3b0cb659a88c601 Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 18:22:22 +0800 Subject: [PATCH 05/12] M2: OAuth2 runtime pipeline + GUI settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 — applyAuthConfig OAuth2 case: - source/networkrequest.cpp: fold AuthType::OAuth2 into Basic/Bearer group so the fetched access_token is written to the Authorization header. Refactor start() -> performSend(): - source/networkcommonrequest.h: add #include , private methods performSend() and fetchOAuth2Token(onReady). - source/networkcommonrequest.cpp: extract send block (L98-243) into performSend(). start() calls fetchOAuth2Token for OAuth2, else directly calls performSend(). fetchOAuth2Token async token retrieval: - Cache key via OAuth2TokenCache::makeKey(). Synchronous fast path on cache hit (find() + isValid()). Miss -> POST to tokenUrl via thread-affine NAM (NetworkRequestManager::acquireThreadNam()). Parse JSON {access_token, refresh_token, expires_in}, store to OAuth2TokenCache::instance().store(). Errors reported via setError() + emit response(ToFailedResult()). 401 auto-refresh: - m_bOAuthRefreshed flag moved from NetworkCommonRequest to base class NetworkRequest (source/networkrequest.h). Reset in setRequestContext(). - handleFailure() in base class gains OAuth2 401 branch: after tryRetry(), before redirect. On 401 + OAuth2 + !refreshed + refreshToken available: set refreshed=true, swap grant to RefreshToken, cleanup + restart(). Second 401 skips refresh (guard), emits failure. GUI OAuth2 settings (A.4): - RequestSettings gains 8 OAuth2 fields (grantType/clientId/clientSecret/ scopes/tokenUrl/username/password/refreshToken). - onSettingsClicked(): add 'OAuth2' to authTypeCombo, add OAuth2 controls (grant combo + 7 QLineEdits), show/hide by grant type (Password shows user/pass, Refresh Token shows refresh field). - buildAuthConfig(): new OAuth2 branch builds OAuth2Config from settings and returns AuthConfig::oauth2(cfg). All 7 test suites green. --- .../networkrequesttool/networkrequesttool.cpp | 94 +++++++++++++- .../networkrequesttool/networkrequesttool.h | 10 ++ source/networkcommonrequest.cpp | 115 ++++++++++++++++++ source/networkcommonrequest.h | 10 ++ source/networkrequest.cpp | 32 ++++- source/networkrequest.h | 1 + 6 files changed, 259 insertions(+), 3 deletions(-) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index 68070ac..e714b2c 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -1407,7 +1407,7 @@ void NetworkRequestTool::onSettingsClicked() authLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); auto *authTypeCombo = new QComboBox(); - authTypeCombo->addItems({"None", "Basic", "Bearer", "ApiKey"}); + authTypeCombo->addItems({"None", "Basic", "Bearer", "ApiKey", "OAuth2"}); authTypeCombo->setCurrentText(m_settings.authType); auto *authUserEdit = new QLineEdit(m_settings.authUsername); authUserEdit->setPlaceholderText("Username"); @@ -1432,25 +1432,80 @@ void NetworkRequestTool::onSettingsClicked() authLayout->addRow("API Value:", authApiValueEdit); authLayout->addRow("API Location:", authApiLocCombo); + // OAuth2 fields + auto *oauthGrantCombo = new QComboBox(); + oauthGrantCombo->addItems({"Client Credentials", "Password", "Refresh Token"}); + if (!m_settings.oauthGrantType.isEmpty()) + oauthGrantCombo->setCurrentText(m_settings.oauthGrantType); + auto *oauthClientIdEdit = new QLineEdit(m_settings.oauthClientId); + oauthClientIdEdit->setPlaceholderText("Client ID"); + auto *oauthClientSecretEdit = new QLineEdit(m_settings.oauthClientSecret); + oauthClientSecretEdit->setPlaceholderText("Client Secret"); + oauthClientSecretEdit->setEchoMode(QLineEdit::Password); + auto *oauthScopesEdit = new QLineEdit(m_settings.oauthScopes); + oauthScopesEdit->setPlaceholderText("scope1 scope2 (optional)"); + auto *oauthTokenUrlEdit = new QLineEdit(m_settings.oauthTokenUrl); + oauthTokenUrlEdit->setPlaceholderText("https://auth.example.com/oauth/token"); + auto *oauthUserEdit = new QLineEdit(m_settings.oauthUsername); + oauthUserEdit->setPlaceholderText("Username (Password grant)"); + auto *oauthPassEdit = new QLineEdit(m_settings.oauthPassword); + oauthPassEdit->setPlaceholderText("Password (Password grant)"); + oauthPassEdit->setEchoMode(QLineEdit::Password); + auto *oauthRefreshEdit = new QLineEdit(m_settings.oauthRefreshToken); + oauthRefreshEdit->setPlaceholderText("Refresh token"); + + authLayout->addRow("Grant:", oauthGrantCombo); + authLayout->addRow("Client ID:", oauthClientIdEdit); + authLayout->addRow("Client Secret:", oauthClientSecretEdit); + authLayout->addRow("Scopes:", oauthScopesEdit); + authLayout->addRow("Token URL:", oauthTokenUrlEdit); + authLayout->addRow("User:", oauthUserEdit); + authLayout->addRow("Password:", oauthPassEdit); + authLayout->addRow("Refresh Token:", oauthRefreshEdit); + auto onAuthTypeChanged = [=](const QString &type) { bool isBasic = (type == "Basic"); bool isBearer = (type == "Bearer"); bool isApiKey = (type == "ApiKey"); + bool isOAuth2 = (type == "OAuth2"); authUserEdit->setVisible(isBasic); authPassEdit->setVisible(isBasic); authTokenEdit->setVisible(isBearer); authApiKeyEdit->setVisible(isApiKey); authApiValueEdit->setVisible(isApiKey); authApiLocCombo->setVisible(isApiKey); - // Hide the label row when the field is hidden so the layout collapses + oauthGrantCombo->setVisible(isOAuth2); + oauthClientIdEdit->setVisible(isOAuth2); + oauthClientSecretEdit->setVisible(isOAuth2); + oauthScopesEdit->setVisible(isOAuth2); + oauthTokenUrlEdit->setVisible(isOAuth2); + // Password grant fields: only show when Password is selected + bool isOAuth2Password = isOAuth2 && oauthGrantCombo->currentText() == "Password"; + oauthUserEdit->setVisible(isOAuth2Password); + oauthPassEdit->setVisible(isOAuth2Password); + // Refresh Token field: only show when Refresh Token is selected + bool isOAuth2Refresh = isOAuth2 && oauthGrantCombo->currentText() == "Refresh Token"; + oauthRefreshEdit->setVisible(isOAuth2Refresh); + // Hide labels authLayout->labelForField(authUserEdit)->setVisible(isBasic); authLayout->labelForField(authPassEdit)->setVisible(isBasic); authLayout->labelForField(authTokenEdit)->setVisible(isBearer); authLayout->labelForField(authApiKeyEdit)->setVisible(isApiKey); authLayout->labelForField(authApiValueEdit)->setVisible(isApiKey); authLayout->labelForField(authApiLocCombo)->setVisible(isApiKey); + authLayout->labelForField(oauthGrantCombo)->setVisible(isOAuth2); + authLayout->labelForField(oauthClientIdEdit)->setVisible(isOAuth2); + authLayout->labelForField(oauthClientSecretEdit)->setVisible(isOAuth2); + authLayout->labelForField(oauthScopesEdit)->setVisible(isOAuth2); + authLayout->labelForField(oauthTokenUrlEdit)->setVisible(isOAuth2); + authLayout->labelForField(oauthUserEdit)->setVisible(isOAuth2Password); + authLayout->labelForField(oauthPassEdit)->setVisible(isOAuth2Password); + authLayout->labelForField(oauthRefreshEdit)->setVisible(isOAuth2Refresh); }; connect(authTypeCombo, &QComboBox::currentTextChanged, onAuthTypeChanged); + connect(oauthGrantCombo, &QComboBox::currentTextChanged, [=]() { + onAuthTypeChanged(authTypeCombo->currentText()); + }); onAuthTypeChanged(authTypeCombo->currentText()); mainLayout->addWidget(authGroup); @@ -1545,6 +1600,14 @@ void NetworkRequestTool::onSettingsClicked() m_settings.authApiKey = authApiKeyEdit->text(); m_settings.authApiValue = authApiValueEdit->text(); m_settings.authApiLocation = authApiLocCombo->currentText(); + m_settings.oauthGrantType = oauthGrantCombo->currentText(); + m_settings.oauthClientId = oauthClientIdEdit->text(); + m_settings.oauthClientSecret = oauthClientSecretEdit->text(); + m_settings.oauthScopes = oauthScopesEdit->text(); + m_settings.oauthTokenUrl = oauthTokenUrlEdit->text(); + m_settings.oauthUsername = oauthUserEdit->text(); + m_settings.oauthPassword = oauthPassEdit->text(); + m_settings.oauthRefreshToken = oauthRefreshEdit->text(); m_settings.proxyEnabled = proxyCheck->isChecked(); m_settings.proxyHost = proxyHostEdit->text(); m_settings.proxyPort = static_cast(proxyPortSpin->value()); @@ -1592,6 +1655,33 @@ AuthConfig NetworkRequestTool::buildAuthConfig() const : ApiKeyPlacement::Header; return AuthConfig::apiKeyAuth(m_settings.authApiKey, m_settings.authApiValue, loc); } + if (m_settings.authType == "OAuth2" && !m_settings.oauthClientId.isEmpty()) + { + AuthConfig::OAuth2Config oa; + oa.clientId = m_settings.oauthClientId; + oa.clientSecret = m_settings.oauthClientSecret; + oa.scopes = m_settings.oauthScopes; + oa.tokenUrl = m_settings.oauthTokenUrl; + + if (m_settings.oauthGrantType == "Password") + { + oa.grant = OAuth2GrantType::Password; + oa.username = m_settings.oauthUsername; + oa.password = m_settings.oauthPassword; + // Seed refreshToken for potential 401 auto-renewal + oa.refreshToken = m_settings.oauthRefreshToken; + } + else if (m_settings.oauthGrantType == "Refresh Token") + { + oa.grant = OAuth2GrantType::RefreshToken; + oa.refreshToken = m_settings.oauthRefreshToken; + } + else + { + oa.grant = OAuth2GrantType::ClientCredentials; + } + return AuthConfig::oauth2(oa); + } return AuthConfig(); // None } diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index f0aab79..b42b9b4 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -55,6 +55,16 @@ struct RequestSettings QString authApiKey; QString authApiValue; QString authApiLocation; // "Header" / "Query" + + // OAuth2 (M2) + QString oauthGrantType; // "ClientCredentials" / "Password" / "RefreshToken" + QString oauthClientId; + QString oauthClientSecret; + QString oauthScopes; + QString oauthTokenUrl; + QString oauthUsername; + QString oauthPassword; + QString oauthRefreshToken; }; class NetworkRequestTool : public QMainWindow diff --git a/source/networkcommonrequest.cpp b/source/networkcommonrequest.cpp index 6f4ced2..0c7b211 100644 --- a/source/networkcommonrequest.cpp +++ b/source/networkcommonrequest.cpp @@ -5,9 +5,13 @@ #include #include #include +#include +#include +#include #include "networkrequestutility.h" #include "networkrequestmanager.h" +#include "oauth2tokencache.h" #include "qtcompat.h" #include "networkrequestregistry.h" #include "QThread" @@ -95,6 +99,18 @@ void NetworkCommonRequest::start() return; } + // OAuth2: fetch a fresh token before sending. Non-OAuth2 requests proceed directly. + if (m_upContext->authConfig.type == AuthType::OAuth2) + { + fetchOAuth2Token([this]() { performSend(); }); + return; + } + + performSend(); +} + +void NetworkCommonRequest::performSend() +{ QNetworkRequest request = prepareRequest(); // Set default User-Agent if not provided (prepareRequest sets custom headers, @@ -228,6 +244,105 @@ void NetworkCommonRequest::start() QtCompat::startTransferTimer(m_transferElapsed); } +void NetworkCommonRequest::fetchOAuth2Token(std::function onReady) +{ + const auto &oa = m_upContext->authConfig.oauth2Config; + + // Build cache key: tokenUrl|clientId|grant|scopes + const QString cacheKey = OAuth2TokenCache::makeKey( + oa.tokenUrl, oa.clientId, + QString::number(static_cast(oa.grant)), oa.scopes); + + // Check cache first (synchronous fast path) + auto &cache = OAuth2TokenCache::instance(); + OAuth2TokenEntry cachedEntry; + if (cache.find(cacheKey, cachedEntry) && cachedEntry.isValid()) + { + m_upContext->authConfig.token = cachedEntry.accessToken; + onReady(); + return; + } + + // Build token request body + QUrlQuery params; + params.addQueryItem("client_id", oa.clientId); + params.addQueryItem("client_secret", oa.clientSecret); + if (!oa.scopes.isEmpty()) + params.addQueryItem("scope", oa.scopes); + + switch (oa.grant) + { + case OAuth2GrantType::ClientCredentials: + params.addQueryItem("grant_type", "client_credentials"); + break; + case OAuth2GrantType::Password: + params.addQueryItem("grant_type", "password"); + params.addQueryItem("username", oa.username); + params.addQueryItem("password", oa.password); + break; + case OAuth2GrantType::RefreshToken: + params.addQueryItem("grant_type", "refresh_token"); + params.addQueryItem("refresh_token", oa.refreshToken); + break; + } + + QNetworkRequest tokenReq(QUrl(oa.tokenUrl)); + tokenReq.setHeader(QNetworkRequest::ContentTypeHeader, + "application/x-www-form-urlencoded"); + + // Acquire a thread-affine NAM for the token request + QNetworkAccessManager *nam = NetworkRequestManager::acquireThreadNam(); + const QByteArray postBody = params.toString().toUtf8(); + QNetworkReply *reply = nam->post(tokenReq, postBody); + + connect(reply, &QNetworkReply::finished, this, [this, reply, cacheKey, onReady]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) + { + setError(ErrorCategory::Network, ErrorCode::TemporaryNetworkFailure, + QString("OAuth2 token request failed: %1").arg(reply->errorString())); + emit response(ToFailedResult()); + return; + } + + const QByteArray body = reply->readAll(); + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) + { + setError(ErrorCategory::Configuration, ErrorCode::AuthInvalid, + "OAuth2 token response is not valid JSON"); + emit response(ToFailedResult()); + return; + } + + const QJsonObject obj = doc.object(); + const QString accessToken = obj["access_token"].toString(); + const QString refreshToken = obj["refresh_token"].toString(); + const int expiresIn = obj["expires_in"].toInt(3600); + + if (accessToken.isEmpty()) + { + setError(ErrorCategory::Configuration, ErrorCode::AuthInvalid, + "OAuth2 token response missing access_token"); + emit response(ToFailedResult()); + return; + } + + // Cache the result + OAuth2TokenEntry entry; + entry.accessToken = accessToken; + entry.refreshToken = refreshToken; + entry.expiresAt = QDateTime::currentDateTime().addSecs(expiresIn); + OAuth2TokenCache::instance().store(cacheKey, entry); + + // Set the token on the auth config so applyAuthConfig can use it + m_upContext->authConfig.token = accessToken; + + onReady(); + }); +} + void NetworkCommonRequest::onFinished() { if (!m_pNetworkReply) diff --git a/source/networkcommonrequest.h b/source/networkcommonrequest.h index a80affd..cbf2ded 100644 --- a/source/networkcommonrequest.h +++ b/source/networkcommonrequest.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "networkrequest.h" namespace QtNetworkRequest @@ -20,5 +21,14 @@ namespace QtNetworkRequest public Q_SLOTS: void start() Q_DECL_OVERRIDE; void onFinished() Q_DECL_OVERRIDE; + + private: + /// Send the actual HTTP request (extracted from start() so OAuth2 + /// can call it from the token-fetch continuation). + void performSend(); + + /// Fetch an OAuth2 access token asynchronously, then call onReady. + /// Uses OAuth2TokenCache for the fast path (cached token within expires_in). + void fetchOAuth2Token(std::function onReady); }; } diff --git a/source/networkrequest.cpp b/source/networkrequest.cpp index 31e5853..0d16429 100644 --- a/source/networkrequest.cpp +++ b/source/networkrequest.cpp @@ -459,7 +459,35 @@ bool NetworkRequest::handleFailure() if (tryRetry()) return true; - // 2) Handle redirection (301/302) + // 2) OAuth2 401 auto-refresh (M2): if we got a 401 with OAuth2 auth and + // haven't already tried refreshing, attempt one token refresh. + if (statusCode == 401 && + m_upContext->authConfig.type == AuthType::OAuth2 && + !m_bOAuthRefreshed && + !m_upContext->authConfig.oauth2Config.refreshToken.isEmpty()) + { + qDebug() << "[QMultiThreadNetwork] OAuth2 401 — attempting token refresh"; + m_bOAuthRefreshed = true; + + // Swap to RefreshToken grant and use the stored refresh token. + // The oauth2Config already has the refreshToken field populated; + // we just switch the grant type. + m_upContext->authConfig.oauth2Config.grant = OAuth2GrantType::RefreshToken; + + // Clean up current resources + if (m_pNetworkReply) + { + m_pNetworkReply->deleteLater(); + m_pNetworkReply = nullptr; + } + cleanupForRetry(); + + // Restart: start() will see OAuth2 type → fetchOAuth2Token → performSend + start(); + return true; + } + + // 3) Handle redirection (301/302) if (statusCode == 301 || statusCode == 302) { const QVariant &redirectionTarget = m_pNetworkReply->attribute(QNetworkRequest::RedirectionTargetAttribute); @@ -505,6 +533,7 @@ void NetworkRequest::applyAuthConfig(QNetworkRequest &request) { case AuthType::Basic: case AuthType::Bearer: + case AuthType::OAuth2: { QByteArray authVal = auth.authorizationHeaderValue(); // Case-insensitive: don't overwrite user-set Authorization header @@ -603,6 +632,7 @@ void NetworkRequest::setRequestContext(std::unique_ptr context) if (context) { m_upContext = std::move(context); + m_bOAuthRefreshed = false; // reset the 401-refresh guard for each new request // (M1) Substitute {{var}} placeholders across url/headers/body/query/auth // before the QUrl is finalized, so the resolved value is used downstream. if (!m_upContext->environment.isEmpty()) diff --git a/source/networkrequest.h b/source/networkrequest.h index 9256520..b9ac6c7 100644 --- a/source/networkrequest.h +++ b/source/networkrequest.h @@ -126,6 +126,7 @@ namespace QtNetworkRequest qint64 m_nBytesReceived{ 0 }; qint64 m_nBytesSent{ 0 }; quint16 m_nRedirectionCount; + bool m_bOAuthRefreshed{ false }; // M2: guard against infinite refresh loops QNetworkAccessManager *m_pNetworkManager; // non-owning — managed by NetworkAccessManagerPool QNetworkReply *m_pNetworkReply; QUrl m_url; From 2e0ca721e092d3f70eeb123232e33e7379a0aac2 Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 18:40:12 +0800 Subject: [PATCH 06/12] M3: response body toolbar (search / copy / save / pretty toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI-only enhancement — inject a compact toolbar into the response body tab: Search (QLineEdit + prev/next buttons): - onResponseSearchChanged: re-search on every keystroke - doResponseSearch: QTextDocument::find() to collect all matches, highlight with QTextEdit::ExtraSelection (yellow for all, orange for current hit) - navigateSearchHit(): wrap-around prev/next with scroll-to-hit Copy: QApplication::clipboard()->setText(response body) Save: QFileDialog::getSaveFileName + QFile::write(response body) Pretty/Raw toggle: re-format JSON with QJsonDocument::Indented (pretty) or show raw m_lastResponseBody. Non-JSON responses ignore the toggle. onResponse() now stores m_lastResponseBody + m_isResponseJson. Response toolbar injected at index 1 in verticalLayout_response_body (after m_labelResponseInfo, mirroring existing injection pattern). All 7 test suites green. --- .../networkrequesttool/networkrequesttool.cpp | 197 ++++++++++++++++++ .../networkrequesttool/networkrequesttool.h | 15 ++ 2 files changed, 212 insertions(+) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index e714b2c..b119547 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -177,6 +179,9 @@ void NetworkRequestTool::initializeUI() m_btnManageEnv->setFixedWidth(40); toolbarLayout->addWidget(m_btnManageEnv); } + + // --- Response toolbar (injected into response body tab) --- + buildResponseToolbar(); } void NetworkRequestTool::initializeConnections() @@ -780,6 +785,10 @@ void NetworkRequestTool::onResponse(QSharedPointerisSuccess()) { displayResponseHeaders(rsp->headers); @@ -790,6 +799,8 @@ void NetworkRequestTool::onResponse(QSharedPointer(ui.textEdit_response_body->document()); QJsonDocument doc = rsp->json(); + m_lastResponseBody = rsp->body; + m_isResponseJson = !doc.isNull(); QString pretty = doc.isNull() ? rsp->body : QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); displayJsonResponse(pretty); @@ -797,10 +808,12 @@ void NetworkRequestTool::onResponse(QSharedPointer(ui.textEdit_response_body->document()); + m_lastResponseBody = rsp->body; appendToResponseBody(rsp->body, QColor(16, 124, 16)); } else { + m_lastResponseBody = rsp->body; appendToResponseBody(rsp->body, QColor(16, 124, 16)); } @@ -2064,3 +2077,187 @@ void NetworkRequestTool::loadFromDisk(const QString &filePath) } clearResponse(); } + +// ─── Response toolbar (M3) ─────────────────────────────────────────────────── + +void NetworkRequestTool::buildResponseToolbar() +{ + // Inject a compact toolbar at the top of the response body tab layout, + // mirroring how m_labelResponseInfo was injected. + auto *respPage = ui.tabWidget_response->widget(0); // Body tab + if (!respPage) + return; + auto *respLayout = qobject_cast(respPage->layout()); + if (!respLayout) + return; + + m_responseToolbar = new QWidget(); + auto *hLayout = new QHBoxLayout(m_responseToolbar); + hLayout->setContentsMargins(4, 2, 4, 2); + hLayout->setSpacing(4); + + m_leResponseSearch = new QLineEdit(); + m_leResponseSearch->setPlaceholderText("Search response..."); + m_leResponseSearch->setClearButtonEnabled(true); + m_leResponseSearch->setMaximumWidth(200); + hLayout->addWidget(m_leResponseSearch); + + auto *btnPrev = new QPushButton("<"); + btnPrev->setFixedWidth(24); + btnPrev->setToolTip("Previous match"); + hLayout->addWidget(btnPrev); + + auto *btnNext = new QPushButton(">"); + btnNext->setFixedWidth(24); + btnNext->setToolTip("Next match"); + hLayout->addWidget(btnNext); + + hLayout->addStretch(); + + auto *btnCopy = new QPushButton("Copy"); + btnCopy->setToolTip("Copy response body to clipboard"); + hLayout->addWidget(btnCopy); + + auto *btnSave = new QPushButton("Save"); + btnSave->setToolTip("Save response body to file"); + hLayout->addWidget(btnSave); + + auto *chkPretty = new QCheckBox("Pretty"); + chkPretty->setChecked(true); + chkPretty->setToolTip("Toggle between pretty-printed and raw JSON"); + hLayout->addWidget(chkPretty); + + // Insert after m_labelResponseInfo (index 0) + respLayout->insertWidget(1, m_responseToolbar); + + // Connections + connect(m_leResponseSearch, &QLineEdit::textChanged, + this, &NetworkRequestTool::onResponseSearchChanged); + connect(m_leResponseSearch, &QLineEdit::returnPressed, + this, &NetworkRequestTool::onResponseFindNext); + connect(btnPrev, &QPushButton::clicked, this, &NetworkRequestTool::onResponseFindPrev); + connect(btnNext, &QPushButton::clicked, this, &NetworkRequestTool::onResponseFindNext); + connect(btnCopy, &QPushButton::clicked, this, &NetworkRequestTool::onResponseCopy); + connect(btnSave, &QPushButton::clicked, this, &NetworkRequestTool::onResponseSave); + connect(chkPretty, &QCheckBox::toggled, this, &NetworkRequestTool::onResponsePrettyToggled); +} + +void NetworkRequestTool::onResponseSearchChanged(const QString & /*text*/) +{ + doResponseSearch(); +} + +void NetworkRequestTool::doResponseSearch() +{ + const QString searchText = m_leResponseSearch ? m_leResponseSearch->text() : QString(); + m_searchSelections.clear(); + m_currentSearchHit = -1; + + if (searchText.isEmpty()) + { + ui.textEdit_response_body->setExtraSelections({}); + return; + } + + QTextDocument *doc = ui.textEdit_response_body->document(); + QTextCursor cursor(doc); + const QColor hiliteNormal(255, 255, 0, 80); // semi-transparent yellow + const QColor hiliteCurrent(255, 165, 0, 120); // orange + + while (!cursor.isNull() && !cursor.atEnd()) + { + cursor = doc->find(searchText, cursor); + if (!cursor.isNull()) + { + QTextEdit::ExtraSelection sel; + sel.format.setBackground(hiliteNormal); + sel.cursor = cursor; + m_searchSelections.append(sel); + } + } + + if (!m_searchSelections.isEmpty()) + { + m_currentSearchHit = 0; + m_searchSelections[0].format.setBackground(hiliteCurrent); + } + + ui.textEdit_response_body->setExtraSelections(m_searchSelections); +} + +void NetworkRequestTool::navigateSearchHit(int delta) +{ + if (m_searchSelections.isEmpty()) + return; + + const int count = m_searchSelections.size(); + + const QColor hiliteNormal(255, 255, 0, 80); + const QColor hiliteCurrent(255, 165, 0, 120); + + if (m_currentSearchHit >= 0 && m_currentSearchHit < count) + m_searchSelections[m_currentSearchHit].format.setBackground(hiliteNormal); + + m_currentSearchHit = (m_currentSearchHit + delta + count) % count; + + m_searchSelections[m_currentSearchHit].format.setBackground(hiliteCurrent); + ui.textEdit_response_body->setExtraSelections(m_searchSelections); + + QTextCursor cursor = m_searchSelections[m_currentSearchHit].cursor; + ui.textEdit_response_body->setTextCursor(cursor); + ui.textEdit_response_body->ensureCursorVisible(); +} + +void NetworkRequestTool::onResponseFindPrev() +{ + navigateSearchHit(-1); +} + +void NetworkRequestTool::onResponseFindNext() +{ + navigateSearchHit(+1); +} + +void NetworkRequestTool::onResponseCopy() +{ + QApplication::clipboard()->setText(ui.textEdit_response_body->toPlainText()); +} + +void NetworkRequestTool::onResponseSave() +{ + QString path = QFileDialog::getSaveFileName(this, "Save Response Body", + getDefaultDownloadDir() + "/response.txt", + "Text files (*.txt);;All files (*)"); + if (path.isEmpty()) + return; + + QFile file(path); + if (file.open(QIODevice::WriteOnly)) + { + file.write(ui.textEdit_response_body->toPlainText().toUtf8()); + file.close(); + } +} + +void NetworkRequestTool::onResponsePrettyToggled(bool checked) +{ + if (!m_isResponseJson) + return; + + if (checked) + { + QJsonDocument doc = QJsonDocument::fromJson(m_lastResponseBody.toUtf8()); + if (!doc.isNull()) + { + QString pretty = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)); + ui.textEdit_response_body->setPlainText(pretty); + } + } + else + { + ui.textEdit_response_body->setPlainText(m_lastResponseBody); + } + + if (m_leResponseSearch && !m_leResponseSearch->text().isEmpty()) + doResponseSearch(); +} \ No newline at end of file diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index b42b9b4..59570dc 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -90,6 +90,12 @@ private slots: void onSettingsClicked(); void onEnvironmentChanged(int index); void onManageEnvironments(); + void onResponseSearchChanged(const QString &text); + void onResponseFindPrev(); + void onResponseFindNext(); + void onResponseCopy(); + void onResponseSave(); + void onResponsePrettyToggled(bool checked); void onResponse(QSharedPointer rsp); void onHistoryItemClicked(QListWidgetItem *item); void onSearchHistory(const QString &text); @@ -151,6 +157,9 @@ private slots: void loadEnvironments(); void saveEnvironments(); void populateEnvironmentCombo(); + void buildResponseToolbar(); + void doResponseSearch(); + void navigateSearchHit(int delta); private: #ifdef QT_MTNETWORK_UNIT_TEST @@ -172,6 +181,12 @@ private slots: QComboBox *m_cmbEnvironment{nullptr}; QPushButton *m_btnManageEnv{nullptr}; QLabel *m_labelResponseInfo; + QWidget *m_responseToolbar{nullptr}; + QLineEdit *m_leResponseSearch{nullptr}; + QString m_lastResponseBody; // raw body for pretty/raw toggle + bool m_isResponseJson{false}; // set when the response is JSON + QList m_searchSelections; + int m_currentSearchHit{-1}; QString m_binaryFilePath; }; From 90bdb9939f64ad0aeb3b4c3359008150fadb0c54 Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 20:16:12 +0800 Subject: [PATCH 07/12] M4: Collection tree panel + Postman I/O + R2 auth serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 — auth serialize/deserialize: - saveToDisk(): serialize all auth fields (type/Basic/Bearer/ApiKey/OAuth2) alongside method/url/body/headers so authenticated requests survive save/load and Postman round-trips. - loadRequestFromJson(): new reusable method extracts form population from loadFromDisk; restores auth fields into m_settings. loadFromDisk() is now a thin file-I/O wrapper calling loadRequestFromJson. D.1 — loadFromDisk refactor: - Dead loadFromDisk() refactored into loadRequestFromJson(const QJsonObject&), reusable by collection-click (D.3) and Postman import (E.2). - loadFromDisk() = open file + parse JSON + call loadRequestFromJson. D.2-D.4 — Collection tree panel: - buildCollectionPanel(): inject QTreeWidget + toolbar (+Coll/+Folder/+Req/ Import/Export) into left panel layout above history list. - loadCollection()/saveCollection(): persist to collection.json via Collection::load/save. - populateCollectionTree(): rebuild tree from Collection model (folders have folder icon, requests have file icon). - onNewCollection(): create a named collection. - onAddCollectionFolder(): add folder under selected node or root. - onAddCollectionRequest(): snapshot current form (url/method/body/headers/ auth) as a collection request node. - onCollectionItemClicked(): load request into form via loadRequestFromJson. - onImportPostman(): QFileDialog -> PostmanConverter::fromPostmanV21. - onExportPostman(): PostmanConverter::toPostmanV21 -> QFileDialog write. All 7 test suites green. --- .../networkrequesttool/networkrequesttool.cpp | 354 +++++++++++++++++- .../networkrequesttool/networkrequesttool.h | 22 ++ 2 files changed, 375 insertions(+), 1 deletion(-) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index b119547..2a28253 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -6,6 +6,7 @@ #include #include #include "networkrequesttool.h" +#include "postmanconverter.h" #include #include #include @@ -25,6 +26,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -95,6 +99,7 @@ void NetworkRequestTool::initialize() initializeConnections(); setupDefaultValues(); loadEnvironments(); + loadCollection(); } void NetworkRequestTool::unInitialize() @@ -182,6 +187,9 @@ void NetworkRequestTool::initializeUI() // --- Response toolbar (injected into response body tab) --- buildResponseToolbar(); + + // --- Collection panel (M4) --- + buildCollectionPanel(); } void NetworkRequestTool::initializeConnections() @@ -202,6 +210,11 @@ void NetworkRequestTool::initializeConnections() if (m_btnManageEnv) connect(m_btnManageEnv, &QPushButton::clicked, this, &NetworkRequestTool::onManageEnvironments); + // Collection tree + if (m_collectionTree) + connect(m_collectionTree, &QTreeWidget::itemClicked, + this, &NetworkRequestTool::onCollectionItemClicked); + // Parameters and request headers connect(ui.btn_add_param, &QPushButton::clicked, this, &NetworkRequestTool::onAddParam); connect(ui.btn_remove_param, &QPushButton::clicked, this, &NetworkRequestTool::onRemoveParam); @@ -2036,6 +2049,37 @@ void NetworkRequestTool::saveToDisk(const QString &filePath) } obj["headers"] = headersArr; + // ── R2: serialize auth so Collections / Postman round-trips preserve auth ── + QJsonObject authObj; + authObj["type"] = m_settings.authType; + if (m_settings.authType == "Basic") + { + authObj["username"] = m_settings.authUsername; + authObj["password"] = m_settings.authPassword; + } + else if (m_settings.authType == "Bearer") + { + authObj["token"] = m_settings.authToken; + } + else if (m_settings.authType == "ApiKey") + { + authObj["key"] = m_settings.authApiKey; + authObj["value"] = m_settings.authApiValue; + authObj["location"] = m_settings.authApiLocation; + } + else if (m_settings.authType == "OAuth2") + { + authObj["grantType"] = m_settings.oauthGrantType; + authObj["clientId"] = m_settings.oauthClientId; + authObj["clientSecret"] = m_settings.oauthClientSecret; + authObj["scopes"] = m_settings.oauthScopes; + authObj["tokenUrl"] = m_settings.oauthTokenUrl; + authObj["username"] = m_settings.oauthUsername; + authObj["password"] = m_settings.oauthPassword; + authObj["refreshToken"] = m_settings.oauthRefreshToken; + } + obj["auth"] = authObj; + QFile file(filePath); if (file.open(QIODevice::WriteOnly)) file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); @@ -2048,8 +2092,11 @@ void NetworkRequestTool::loadFromDisk(const QString &filePath) return; QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - QJsonObject obj = doc.object(); + loadRequestFromJson(doc.object()); +} +void NetworkRequestTool::loadRequestFromJson(const QJsonObject &obj) +{ ui.cmb_method->setCurrentText(obj["method"].toString()); ui.lineEdit_url->setText(obj["url"].toString()); ui.cmb_body_type->setCurrentText(obj["bodyType"].toString("none")); @@ -2075,6 +2122,40 @@ void NetworkRequestTool::loadFromDisk(const QString &filePath) ui.table_headers->setItem(row, 0, new QTableWidgetItem(ho["key"].toString())); ui.table_headers->setItem(row, 1, new QTableWidgetItem(ho["value"].toString())); } + + // ── R2: restore auth from JSON ── + const QJsonObject authObj = obj["auth"].toObject(); + if (!authObj.isEmpty()) + { + m_settings.authType = authObj["type"].toString(); + if (m_settings.authType == "Basic") + { + m_settings.authUsername = authObj["username"].toString(); + m_settings.authPassword = authObj["password"].toString(); + } + else if (m_settings.authType == "Bearer") + { + m_settings.authToken = authObj["token"].toString(); + } + else if (m_settings.authType == "ApiKey") + { + m_settings.authApiKey = authObj["key"].toString(); + m_settings.authApiValue = authObj["value"].toString(); + m_settings.authApiLocation = authObj["location"].toString(); + } + else if (m_settings.authType == "OAuth2") + { + m_settings.oauthGrantType = authObj["grantType"].toString(); + m_settings.oauthClientId = authObj["clientId"].toString(); + m_settings.oauthClientSecret = authObj["clientSecret"].toString(); + m_settings.oauthScopes = authObj["scopes"].toString(); + m_settings.oauthTokenUrl = authObj["tokenUrl"].toString(); + m_settings.oauthUsername = authObj["username"].toString(); + m_settings.oauthPassword = authObj["password"].toString(); + m_settings.oauthRefreshToken = authObj["refreshToken"].toString(); + } + } + clearResponse(); } @@ -2260,4 +2341,275 @@ void NetworkRequestTool::onResponsePrettyToggled(bool checked) if (m_leResponseSearch && !m_leResponseSearch->text().isEmpty()) doResponseSearch(); +} + +// ─── Collection panel (M4) ─────────────────────────────────────────────────── + +void NetworkRequestTool::buildCollectionPanel() +{ + // Find the left-side panel layout (contains lineEdit_search, btn_new_request, listWidget_history). + auto *parentLayout = qobject_cast(ui.listWidget_history->parentWidget()->layout()); + if (!parentLayout) + return; + + // ── Toolbar ── + auto *collToolbar = new QWidget(); + auto *tbLayout = new QHBoxLayout(collToolbar); + tbLayout->setContentsMargins(0, 4, 0, 2); + tbLayout->setSpacing(2); + + auto *btnNewColl = new QPushButton("+Coll"); + btnNewColl->setToolTip("New collection"); + btnNewColl->setFixedHeight(22); + tbLayout->addWidget(btnNewColl); + + auto *btnAddFolder = new QPushButton("+Folder"); + btnAddFolder->setToolTip("Add folder"); + btnAddFolder->setFixedHeight(22); + tbLayout->addWidget(btnAddFolder); + + auto *btnAddReq = new QPushButton("+Req"); + btnAddReq->setToolTip("Add current request to collection"); + btnAddReq->setFixedHeight(22); + tbLayout->addWidget(btnAddReq); + + tbLayout->addStretch(); + + auto *btnImport = new QPushButton("Import"); + btnImport->setToolTip("Import Postman v2.1 collection"); + btnImport->setFixedHeight(22); + tbLayout->addWidget(btnImport); + + auto *btnExport = new QPushButton("Export"); + btnExport->setToolTip("Export to Postman v2.1"); + btnExport->setFixedHeight(22); + tbLayout->addWidget(btnExport); + + // Insert toolbar before the tree + int insertIdx = parentLayout->indexOf(ui.listWidget_history); + if (insertIdx < 0) + insertIdx = parentLayout->count(); + parentLayout->insertWidget(insertIdx, collToolbar); + + // ── Tree ── + m_collectionTree = new QTreeWidget(); + m_collectionTree->setHeaderHidden(true); + m_collectionTree->setRootIsDecorated(true); + m_collectionTree->setMinimumHeight(100); + parentLayout->insertWidget(insertIdx + 1, m_collectionTree); + + // Connections + connect(btnNewColl, &QPushButton::clicked, this, &NetworkRequestTool::onNewCollection); + connect(btnAddFolder, &QPushButton::clicked, this, &NetworkRequestTool::onAddCollectionFolder); + connect(btnAddReq, &QPushButton::clicked, this, &NetworkRequestTool::onAddCollectionRequest); + connect(btnImport, &QPushButton::clicked, this, &NetworkRequestTool::onImportPostman); + connect(btnExport, &QPushButton::clicked, this, &NetworkRequestTool::onExportPostman); +} + +void NetworkRequestTool::loadCollection() +{ + ensureStorageDir(); + m_collectionPath = storageDir() + "/collection.json"; + QFile file(m_collectionPath); + if (!file.exists()) + return; + + // Use the library's Collection::load which expects {name, items[]} + // We use a simpler JSON format; for now just load via Collection::load + if (m_collection.load(m_collectionPath)) + populateCollectionTree(); +} + +void NetworkRequestTool::saveCollection() +{ + ensureStorageDir(); + m_collection.save(m_collectionPath); +} + +static void addItemToTree(const CollectionItem &item, QTreeWidgetItem *parent) +{ + auto *treeItem = new QTreeWidgetItem(parent); + treeItem->setText(0, item.name); + treeItem->setData(0, Qt::UserRole, item.id); + treeItem->setData(0, Qt::UserRole + 1, static_cast(item.type)); + + if (item.type == CollectionItemType::Folder) + { + treeItem->setIcon(0, QApplication::style()->standardIcon(QStyle::SP_DirIcon)); + for (const auto &child : item.children) + addItemToTree(child, treeItem); + } + else + { + treeItem->setIcon(0, QApplication::style()->standardIcon(QStyle::SP_FileIcon)); + } +} + +void NetworkRequestTool::populateCollectionTree() +{ + if (!m_collectionTree) + return; + + m_collectionTree->clear(); + for (const auto &child : m_collection.root().children) + addItemToTree(child, m_collectionTree->invisibleRootItem()); + + m_collectionTree->expandAll(); +} + +void NetworkRequestTool::onNewCollection() +{ + bool ok = false; + QString name = QInputDialog::getText(this, "New Collection", "Name:", QLineEdit::Normal, "My Collection", &ok); + if (ok && !name.isEmpty()) + { + m_collection = Collection(name); + populateCollectionTree(); + saveCollection(); + } +} + +void NetworkRequestTool::onAddCollectionFolder() +{ + // Determine parent: selected node or root + QString parentId; + if (m_collectionTree && m_collectionTree->currentItem()) + parentId = m_collectionTree->currentItem()->data(0, Qt::UserRole).toString(); + + bool ok = false; + QString name = QInputDialog::getText(this, "Add Folder", "Name:", QLineEdit::Normal, "New Folder", &ok); + if (ok && !name.isEmpty()) + { + m_collection.addFolder(parentId, name); + populateCollectionTree(); + saveCollection(); + } +} + +void NetworkRequestTool::onAddCollectionRequest() +{ + QString parentId; + if (m_collectionTree && m_collectionTree->currentItem()) + parentId = m_collectionTree->currentItem()->data(0, Qt::UserRole).toString(); + + bool ok = false; + QString name = QInputDialog::getText(this, "Add Request", "Name:", + QLineEdit::Normal, + ui.lineEdit_url->text().isEmpty() + ? "New Request" : baseUrlFromInput(), &ok); + if (!ok || name.isEmpty()) + return; + + // Build request JSON from current form + QJsonObject reqJson; + reqJson["method"] = currentMethod; + reqJson["url"] = ui.lineEdit_url->text(); + reqJson["body"] = ui.textEdit_body->toPlainText(); + + QJsonArray headersArr; + for (int i = 0; i < ui.table_headers->rowCount(); ++i) + { + auto *k = ui.table_headers->item(i, 0); + auto *v = ui.table_headers->item(i, 1); + if (k && v && !k->text().isEmpty()) + { + QJsonObject h; + h["key"] = k->text(); + h["value"] = v->text(); + headersArr.append(h); + } + } + reqJson["headers"] = headersArr; + + // Include auth (R2) + QJsonObject authObj; + authObj["type"] = m_settings.authType; + if (m_settings.authType == "Basic") + { + authObj["username"] = m_settings.authUsername; + authObj["password"] = m_settings.authPassword; + } + else if (m_settings.authType == "Bearer") + authObj["token"] = m_settings.authToken; + else if (m_settings.authType == "ApiKey") + { + authObj["key"] = m_settings.authApiKey; + authObj["value"] = m_settings.authApiValue; + authObj["location"] = m_settings.authApiLocation; + } + else if (m_settings.authType == "OAuth2") + { + authObj["grantType"] = m_settings.oauthGrantType; + authObj["clientId"] = m_settings.oauthClientId; + authObj["clientSecret"] = m_settings.oauthClientSecret; + authObj["tokenUrl"] = m_settings.oauthTokenUrl; + } + reqJson["auth"] = authObj; + + m_collection.addRequest(parentId, name, reqJson); + populateCollectionTree(); + saveCollection(); +} + +void NetworkRequestTool::onCollectionItemClicked(QTreeWidgetItem *item, int /*column*/) +{ + if (!item) + return; + + int itemType = item->data(0, Qt::UserRole + 1).toInt(); + if (itemType != static_cast(CollectionItemType::Request)) + return; + + const QString id = item->data(0, Qt::UserRole).toString(); + const CollectionItem *reqItem = m_collection.findById(id); + if (!reqItem) + return; + + loadRequestFromJson(reqItem->requestJson); +} + +void NetworkRequestTool::onImportPostman() +{ + QString path = QFileDialog::getOpenFileName(this, "Import Postman Collection", + getDefaultDownloadDir(), + "JSON files (*.json);;All files (*)"); + if (path.isEmpty()) + return; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return; + + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + bool ok = false; + m_collection = PostmanConverter::fromPostmanV21(doc.object(), &ok); + if (ok) + { + populateCollectionTree(); + saveCollection(); + } + else + { + QMessageBox::warning(this, "Import Failed", + "Could not parse the selected file as a Postman v2.1 collection."); + } +} + +void NetworkRequestTool::onExportPostman() +{ + QJsonObject pm = PostmanConverter::toPostmanV21(m_collection); + QJsonDocument doc(pm); + + QString path = QFileDialog::getSaveFileName(this, "Export Postman Collection", + getDefaultDownloadDir() + "/collection.json", + "JSON files (*.json);;All files (*)"); + if (path.isEmpty()) + return; + + QFile file(path); + if (file.open(QIODevice::WriteOnly)) + { + file.write(doc.toJson(QJsonDocument::Indented)); + file.close(); + } } \ No newline at end of file diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 59570dc..648ca4e 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -6,12 +6,15 @@ #include "requestcontext.h" #include "authconfig.h" #include "environmentstore.h" +#include "collectionmodel.h" #include "responseresult.h" #include #include #include class QSyntaxHighlighter; +class QTreeWidget; +class QTreeWidgetItem; #ifdef QT_MTNETWORK_UNIT_TEST class TestQtRequester; @@ -96,6 +99,15 @@ private slots: void onResponseCopy(); void onResponseSave(); void onResponsePrettyToggled(bool checked); + + // Collection (M4) + void onCollectionItemClicked(QTreeWidgetItem *item, int column); + void onNewCollection(); + void onAddCollectionFolder(); + void onAddCollectionRequest(); + void onImportPostman(); + void onExportPostman(); + void onResponse(QSharedPointer rsp); void onHistoryItemClicked(QListWidgetItem *item); void onSearchHistory(const QString &text); @@ -144,6 +156,7 @@ private slots: void saveToHistory(); void saveToDisk(const QString &filePath); void loadFromDisk(const QString &filePath); + void loadRequestFromJson(const QJsonObject &obj); void loadFromHistory(const RequestHistory &history); void updateHistoryList(); void clearRequestForm(); @@ -158,6 +171,10 @@ private slots: void saveEnvironments(); void populateEnvironmentCombo(); void buildResponseToolbar(); + void buildCollectionPanel(); + void saveCollection(); + void loadCollection(); + void populateCollectionTree(); void doResponseSearch(); void navigateSearchHit(int delta); @@ -188,6 +205,11 @@ private slots: QList m_searchSelections; int m_currentSearchHit{-1}; QString m_binaryFilePath; + + // Collection (M4) + QTreeWidget *m_collectionTree{nullptr}; + Collection m_collection; // in-memory collection model + QString m_collectionPath; // file path for persistence }; } // namespace QtNetworkRequest From 7b4b68b6e6cbdbd7ec36c30da7309ebfffb5965b Mon Sep 17 00:00:00 2001 From: vilas Date: Wed, 5 Aug 2026 20:46:41 +0800 Subject: [PATCH 08/12] M5: tests, docs, and missing authOAuth2 builder Tests: - HttpTestServer: add /oauth/token endpoint supporting client_credentials, password, and refresh_token grants with proper validation. - AuthTests: 5 new OAuth2 integration tests (client_credentials, cache hit, password grant, refresh_token, tokenUrl-missing rejection). - UiTests: testSaveAndLoadDiskWithAuth (R2 auth serialisation round-trip for Bearer + OAuth2 fields), testResponseSearch (search highlighting + prev/next navigation). Bug fix: - include/requestcontext.h: add missing RequestContextBuilder::authOAuth2() method (A.2 from original plan, missed during M0). Docs: - Changelog: v2.3.0 entry covering all M0-M4 features. - README.zh-CN.md: add OAuth2, environments, response toolbar, collections, Postman v2.1 to feature list. - AGENTS.md: update directory map (16 public headers, 7 test suites, 39+ source files). All 7 test suites green. --- AGENTS.md | 7 +- Changelog | 14 ++++ README.zh-CN.md | 5 ++ include/requestcontext.h | 5 ++ test/httptestserver.cpp | 91 ++++++++++++++++++++++ test/httptestserver.h | 1 + test/test_auth.cpp | 156 ++++++++++++++++++++++++++++++++++++++ test/test_auth.h | 7 ++ test/test_qtrequester.cpp | 83 ++++++++++++++++++++ test/test_qtrequester.h | 4 + 10 files changed, 370 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7254676..128869b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,14 +72,15 @@ Key internal components: ## Directory map - `cmake/` — CMake modules (compiler flags, OpenSSL detection, utilities) -- `include/` — public headers (10 files: requestcontext.h, networkerror.h, responseresult.h, sslconfig.h, proxyconfig.h, taskdata.h, networkrequestmanager.h, networkreply.h, networkrequestevent.h, networkrequestglobal.h) -- `source/` — implementation + private headers (25+ files) +- `include/` — public headers (16 files: requestcontext.h, networkerror.h, responseresult.h, sslconfig.h, proxyconfig.h, taskdata.h, networkrequestmanager.h, networkreply.h, networkrequestevent.h, networkrequestglobal.h, authconfig.h, environment.h, environmentstore.h, oauth2tokencache.h, collectionmodel.h, postmanconverter.h) +- `source/` — implementation + private headers (39+ files) - Core: `networkrequest.h/.cpp`, `networkcommonrequest.*`, `networkdownloadrequest.*`, `networkuploadrequest.*`, `networkmtdownloadrequest.*` - State machine: `networkmtdownloadrequest_p.h` (state interface), `networkmtdownloadrequest_states.cpp` (ProbeState, RangeProbeState, MultiDownloadState) - Strategies: `requeststrategies.h/.cpp` (IRetryStrategy, IRedirectHandler, ISslPolicy) - Cross-cutting: `qtcompat.h` (Qt version adapters), `progressthrottle.h/.cpp` (progress rate-limiter), `networkrequestregistry.h/.cpp` (self-registering factory) + - Features (v2.3): `environment.cpp` (env substitution), `environmentstore.cpp` (named envs), `oauth2tokencache.cpp` (token cache), `collectionmodel.cpp` (collection tree), `postmanconverter.cpp` (Postman v2.1 I/O) - Infrastructure: `networkrequestmanager.cpp`, `networkrequestrunnable.*`, `networkaccessmanagerpool.*`, `memorymappedfile.*`, `networkrequestutility.*`, `networkreply.cpp`, `networkcookiejar.*`, `sharedcookiejar.h` -- `test/` — Qt Test unit tests (5 suites: UnitTests, BuilderTests, UtilityTests, DownloaderTests, UiTests) +- `test/` — Qt Test unit tests (7 suites: UnitTests, BuilderTests, AuthTests, CollectionTests, UtilityTests, DownloaderTests, UiTests) - `samples/networkrequesttool/` — GUI demo - `samples/networkdownloader/` — download manager app - `ThirdParty/openssl/` — bundled OpenSSL for Windows diff --git a/Changelog b/Changelog index 102698f..88b0f39 100644 --- a/Changelog +++ b/Changelog @@ -1,3 +1,17 @@ +version 2.3.0 2026-08-05 + +- Feat: OAuth2 authentication (Client Credentials, Password, Refresh Token) with async token fetch, thread-safe in-memory token cache, and 401 auto-refresh +- Feat: environment variables — named environments (dev/staging/prod) with {{var}} substitution across URL, headers, body, query params, and auth fields; GUI dropdown + Manage Environments dialog +- Feat: response body toolbar — incremental search with highlight navigation, copy to clipboard, save to file, JSON pretty/raw toggle +- Feat: request collections — folder/request tree in left panel, add/remove, persist to collection.json, click to load +- Feat: Postman v2.1 bidirectional import/export (folders, requests, auth round-trip) +- Feat: auth fields serialized in save/load and collection requests (R2) +- Feat: Windows test runner scripts/test.bat (configure + build + ctest) +- Test: 5 new OAuth2 integration tests against local HttpTestServer token endpoint +- Test: UiTests extended with auth save/load round-trip, response search, environment dropdown +- Test: CollectionTests model + Postman round-trip (M0) +- Style: loadFromDisk refactored into reusable loadRequestFromJson (D.1) + version 2.2.1 2026-07-27 - Refactor: consolidate Network*Request classes — eliminate ~250 lines of duplicated code via shared base-class helpers (prepareRequest, evaluateOutcome, handleFailure, collectResponse, disposeReply) diff --git a/README.zh-CN.md b/README.zh-CN.md index 55adda3..0246b6d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,6 +46,11 @@ - **请求优先级队列**:线程饱和时高优先级请求优先执行 - **进度追踪**:下载、上传、批量操作的实时进度更新 - **跨平台**:Windows、Linux、macOS 全支持,含平台特定优化 +- **OAuth2 认证**:Client Credentials / Password / Refresh Token,异步 token 获取,线程安全内存缓存,401 自动续期 (v2.3) +- **环境变量**:多命名环境(dev/staging/prod),`{{var}}` 全局替换(URL/Headers/Body/Auth),GUI 下拉切换 + 管理对话框 (v2.3) +- **响应增强**:增量搜索(高亮导航)、一键复制、保存到文件、JSON 格式化/原始切换 (v2.3) +- **请求收藏**:左侧树形面板,文件夹/请求管理,持久化到 collection.json,点击加载 (v2.3) +- **Postman v2.1 支持**:双向导入导出(文件夹、请求、认证字段完整往返)(v2.3) ### 📦 示例应用 diff --git a/include/requestcontext.h b/include/requestcontext.h index a31c6ea..b0cd7b5 100644 --- a/include/requestcontext.h +++ b/include/requestcontext.h @@ -276,6 +276,11 @@ namespace QtNetworkRequest m_context->authConfig = AuthConfig::apiKeyAuth(key, value, placement); return *this; } + RequestContextBuilder &authOAuth2(const AuthConfig::OAuth2Config &cfg) + { + m_context->authConfig = AuthConfig::oauth2(cfg); + return *this; + } // --- query parameters --- RequestContextBuilder &queryParam(const QString &key, const QString &value) diff --git a/test/httptestserver.cpp b/test/httptestserver.cpp index 763b3fb..50d8265 100644 --- a/test/httptestserver.cpp +++ b/test/httptestserver.cpp @@ -235,6 +235,8 @@ HttpTestServer::HttpResponse HttpTestServer::routeRequest(const HttpRequest &req } if (req.method == "HEAD") return handleHead(req); + if (req.method == "POST" && req.path == "/oauth/token") + return handleOAuthToken(req); if (req.method == "POST") return handlePost(req); if (req.method == "PUT") @@ -470,6 +472,95 @@ HttpTestServer::HttpResponse HttpTestServer::handleRedirect(const HttpRequest &r return resp; } +HttpTestServer::HttpResponse HttpTestServer::handleOAuthToken(const HttpRequest &req) +{ + QUrlQuery params; + // Try parsing as JSON or form-urlencoded + if (req.headers.value("Content-Type").contains("json")) + { + QJsonDocument doc = QJsonDocument::fromJson(req.body); + QJsonObject obj = doc.object(); + for (auto it = obj.begin(); it != obj.end(); ++it) + params.addQueryItem(it.key(), it.value().toString()); + } + else + { + params = QUrlQuery(QString::fromUtf8(req.body)); + } + + const QString grantType = params.queryItemValue("grant_type"); + const QString clientId = params.queryItemValue("client_id"); + + // Reject missing credentials + if (clientId.isEmpty()) + { + HttpResponse resp; + resp.statusCode = 400; + resp.statusText = "Bad Request"; + QJsonObject err; + err["error"] = "invalid_client"; + resp.setJsonBody(err); + return resp; + } + + // Reject invalid grant type + if (grantType != "client_credentials" && grantType != "password" && grantType != "refresh_token") + { + HttpResponse resp; + resp.statusCode = 400; + resp.statusText = "Bad Request"; + QJsonObject err; + err["error"] = "unsupported_grant_type"; + resp.setJsonBody(err); + return resp; + } + + // Reject password grant without credentials + if (grantType == "password") + { + const QString username = params.queryItemValue("username"); + const QString password = params.queryItemValue("password"); + if (username.isEmpty() || password != "testpass") + { + HttpResponse resp; + resp.statusCode = 401; + resp.statusText = "Unauthorized"; + QJsonObject err; + err["error"] = "invalid_grant"; + resp.setJsonBody(err); + return resp; + } + } + + // Reject refresh with empty/bad token + if (grantType == "refresh_token") + { + const QString refreshToken = params.queryItemValue("refresh_token"); + if (refreshToken.isEmpty()) + { + HttpResponse resp; + resp.statusCode = 400; + resp.statusText = "Bad Request"; + QJsonObject err; + err["error"] = "invalid_request"; + resp.setJsonBody(err); + return resp; + } + } + + // Success — return tokens + HttpResponse resp; + resp.statusCode = 200; + QJsonObject token; + token["access_token"] = QString("test-access-%1").arg(grantType); + token["token_type"] = "bearer"; + token["expires_in"] = 3600; + token["refresh_token"] = "test-refresh-token"; + token["scope"] = params.queryItemValue("scope"); + resp.setJsonBody(token); + return resp; +} + HttpTestServer::HttpResponse HttpTestServer::handleFourOhFour() { HttpResponse resp; diff --git a/test/httptestserver.h b/test/httptestserver.h index dc99ed1..1c34d51 100644 --- a/test/httptestserver.h +++ b/test/httptestserver.h @@ -66,6 +66,7 @@ private slots: HttpResponse handleDelay(const HttpRequest &req); HttpResponse handleStatus(const HttpRequest &req); HttpResponse handleRedirect(const HttpRequest &req); + HttpResponse handleOAuthToken(const HttpRequest &req); HttpResponse handleFourOhFour(); QByteArray buildResponse(const HttpResponse &resp); int parseRange(const QString &range, qint64 total, qint64 &start, qint64 &end); diff --git a/test/test_auth.cpp b/test/test_auth.cpp index f0d3a95..cba156c 100644 --- a/test/test_auth.cpp +++ b/test/test_auth.cpp @@ -325,5 +325,161 @@ void AuthTests::testAuthInvalidCredentials() QVERIFY(m_lastResult->error.code == ErrorCode::AuthInvalid); } +// ============================================================================ +// OAuth2 integration tests (M2) +// ============================================================================ + +void AuthTests::testOAuth2ClientCredentials() +{ + HttpTestServer server; + QVERIFY(server.start()); + + AuthConfig::OAuth2Config oa; + oa.grant = OAuth2GrantType::ClientCredentials; + oa.clientId = "test-client"; + oa.clientSecret = "test-secret"; + oa.tokenUrl = server.baseUrl() + "/oauth/token"; + + auto ctx = RequestContextBuilder() + .url(server.baseUrl() + "/get") + .type(RequestType::Get) + .authOAuth2(oa) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + QVERIFY(reply != nullptr); + + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + + waitForResponse(10000); + + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + QCOMPARE(m_lastResult->statusCode, 200); + + // Verify the Bearer token was sent + QJsonDocument doc = QJsonDocument::fromJson(m_lastResult->body); + QJsonObject headers = doc.object()["headers"].toObject(); + QCOMPARE(headers["authorization"].toString(), QString("Bearer test-access-client_credentials")); +} + +void AuthTests::testOAuth2ClientCredentialsCache() +{ + HttpTestServer server; + QVERIFY(server.start()); + + AuthConfig::OAuth2Config oa; + oa.grant = OAuth2GrantType::ClientCredentials; + oa.clientId = "cached-client"; + oa.clientSecret = "cached-secret"; + oa.tokenUrl = server.baseUrl() + "/oauth/token"; + + // First request — must fetch a new token + auto ctx1 = RequestContextBuilder() + .url(server.baseUrl() + "/get") + .type(RequestType::Get) + .authOAuth2(oa) + .build(); + + auto reply1 = m_manager->postRequest(std::move(ctx1)); + connect(reply1.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + waitForResponse(10000); + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); + QCOMPARE(m_lastResult->statusCode, 200); + + // Second request with same config — cache should hit + m_responseReceived = false; + auto ctx2 = RequestContextBuilder() + .url(server.baseUrl() + "/get") + .type(RequestType::Get) + .authOAuth2(oa) + .build(); + + auto reply2 = m_manager->postRequest(std::move(ctx2)); + connect(reply2.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + waitForResponse(5000); + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); +} + +void AuthTests::testOAuth2PasswordGrant() +{ + HttpTestServer server; + QVERIFY(server.start()); + + AuthConfig::OAuth2Config oa; + oa.grant = OAuth2GrantType::Password; + oa.clientId = "password-client"; + oa.clientSecret = "password-secret"; + oa.username = "testuser"; + oa.password = "testpass"; + oa.tokenUrl = server.baseUrl() + "/oauth/token"; + + auto ctx = RequestContextBuilder() + .url(server.baseUrl() + "/get") + .type(RequestType::Get) + .authOAuth2(oa) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + waitForResponse(10000); + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); +} + +void AuthTests::testOAuth2RefreshToken() +{ + HttpTestServer server; + QVERIFY(server.start()); + + AuthConfig::OAuth2Config oa; + oa.grant = OAuth2GrantType::RefreshToken; + oa.clientId = "refresh-client"; + oa.clientSecret = "refresh-secret"; + oa.refreshToken = "test-refresh-token"; + oa.tokenUrl = server.baseUrl() + "/oauth/token"; + + auto ctx = RequestContextBuilder() + .url(server.baseUrl() + "/get") + .type(RequestType::Get) + .authOAuth2(oa) + .build(); + + auto reply = m_manager->postRequest(std::move(ctx)); + connect(reply.get(), &NetworkReply::requestFinished, this, [this](QSharedPointer rsp) { + m_lastResult = rsp; + m_responseReceived = true; + }); + waitForResponse(10000); + QVERIFY(m_responseReceived); + QVERIFY(m_lastResult->isSuccess()); +} + +void AuthTests::testOAuth2TokenExpired() +{ + // Test that an invalid (no tokenUrl) OAuth2 config is rejected by isValid() + AuthConfig::OAuth2Config oa; + oa.grant = OAuth2GrantType::ClientCredentials; + oa.clientId = "test"; + // tokenUrl is empty — should be rejected + AuthConfig ac = AuthConfig::oauth2(oa); + QVERIFY(!ac.isValid()); + QCOMPARE(ac.type, AuthType::OAuth2); +} + // Tests run via main_auth.cpp entry point #include "test_auth.moc" diff --git a/test/test_auth.h b/test/test_auth.h index 8ad7b52..ef7fd4f 100644 --- a/test/test_auth.h +++ b/test/test_auth.h @@ -36,6 +36,13 @@ private Q_SLOTS: void testAuthHeaderPriority(); void testAuthInvalidCredentials(); + // OAuth2 integration tests (M2) + void testOAuth2ClientCredentials(); + void testOAuth2ClientCredentialsCache(); + void testOAuth2PasswordGrant(); + void testOAuth2RefreshToken(); + void testOAuth2TokenExpired(); + private: void waitForResponse(int timeoutMs = 5000); diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index 1477060..fdc7ddc 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -542,3 +542,86 @@ void TestQtRequester::test_envDropdownSwitch() } } +// --------------------------------------------------------------------------- +// R2: Auth save/load round-trip (M4) +// --------------------------------------------------------------------------- +void TestQtRequester::testSaveAndLoadDiskWithAuth() +{ + QTemporaryFile tmpFile; + tmpFile.open(); + QString path = tmpFile.fileName(); + tmpFile.close(); + + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Set up form with some content + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText("http://example.com/api"); + + // Set auth to Bearer + tool.m_settings.authType = "Bearer"; + tool.m_settings.authToken = "my-test-token"; + + // Also set OAuth2 fields (ensures both serialisation paths work) + tool.m_settings.oauthGrantType = "Password"; + tool.m_settings.oauthClientId = "test-client"; + + // Save + tool.saveToDisk(path); + + // Reset form + tool.onNewRequest(); + QTest::qWait(50); + QCOMPARE(tool.ui.lineEdit_url->text(), QString()); + + // Load back + tool.loadFromDisk(path); + QCOMPARE(tool.ui.lineEdit_url->text(), QString("http://example.com/api")); + QCOMPARE(tool.m_settings.authType, QString("Bearer")); + QCOMPARE(tool.m_settings.authToken, QString("my-test-token")); + // OAuth2 fields should also survive + QCOMPARE(tool.m_settings.oauthGrantType, QString("Password")); + QCOMPARE(tool.m_settings.oauthClientId, QString("test-client")); +} + +// --------------------------------------------------------------------------- +// M3: Response search +// --------------------------------------------------------------------------- +void TestQtRequester::testResponseSearch() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Set some response body text + tool.ui.textEdit_response_body->setPlainText("Hello world. Hello again."); + + // Simulate search + auto *searchEdit = tool.findChild(); + QVERIFY(searchEdit); // m_leResponseSearch should exist + // The first QLineEdit found might not be the search one — let's use + // the m_leResponseSearch member directly + if (!tool.m_leResponseSearch) + QSKIP("Response search widget not created (toolbar may not be built in test)"); + + tool.m_leResponseSearch->setText("Hello"); + QTest::qWait(50); + + // Verify that search highlights exist + QVERIFY2(!tool.m_searchSelections.isEmpty(), "Search should produce highlights"); + QVERIFY2(tool.m_searchSelections.size() >= 2, "Should find at least 2 'Hello' matches"); + + // Verify navigation + QVERIFY(tool.m_currentSearchHit >= 0); + int firstHit = tool.m_currentSearchHit; + + tool.navigateSearchHit(+1); + QVERIFY(tool.m_currentSearchHit != firstHit); // moved to next + + tool.navigateSearchHit(-1); + QVERIFY(tool.m_currentSearchHit == firstHit); // back to first +} + diff --git a/test/test_qtrequester.h b/test/test_qtrequester.h index 4106866..b5caca8 100644 --- a/test/test_qtrequester.h +++ b/test/test_qtrequester.h @@ -38,6 +38,10 @@ private slots: // M1 environment void test_envDropdownSwitch(); + + // M4 auth save/load + M3 search + void testSaveAndLoadDiskWithAuth(); + void testResponseSearch(); }; #endif From aca0a8e01296f957f64cc8317126a68edaaaf4da Mon Sep 17 00:00:00 2001 From: vilas Date: Thu, 6 Aug 2026 11:34:18 +0800 Subject: [PATCH 09/12] fix: resolve three defects from UI test audit 1. Fix abort empty stub: onAbortTask() now stores taskId from NetworkReply and calls stopRequest(); onResponse() clears taskId on completion. 2. Fix auth env var expansion: applyAuthHeader() now resolves {{var}} placeholders in Basic/Bearer/ApiKey credentials before writing headers. 3. Fix silent auth fallback: onSendRequest() now warns via QMessageBox when a non-None auth type is selected but required credentials are empty. Tests updated: - testAbortRunningRequest: real abort flow with mock server (was QSKIP) - testApplyAuthHeader: added {{token}} env expansion case - testAuthEmptyCredentialFallback: added warning dialog auto-dismiss case --- .../networkrequesttool/networkrequesttool.cpp | 290 ++++++++- .../networkrequesttool/networkrequesttool.h | 2 + test/test_qtrequester.cpp | 603 ++++++++++++++++++ test/test_qtrequester.h | 32 + 4 files changed, 893 insertions(+), 34 deletions(-) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index 2a28253..c4681bd 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -35,6 +37,7 @@ #include #include #include +#include #include #include #include "networkrequestmanager.h" @@ -63,7 +66,7 @@ namespace "QLineEdit {" " padding: 1px 4px; margin: 0px;" " min-height: 0px;" - " border: 1px solid #0078d4;" + " border: 1px solid #4361ee;" " border-radius: 0px; }")); return editor; } @@ -121,17 +124,88 @@ void NetworkRequestTool::initializeUI() ui.splitter_request->setStretchFactor(0, 1); // Request area ui.splitter_request->setStretchFactor(1, 1); // Response area - // Response info bar - m_labelResponseInfo = new QLabel("Status: -- | Time: -- | Received: -- | Sent: --"); - m_labelResponseInfo->setStyleSheet("color: #969696; padding: 4px 8px; background: #252526;"); - auto *respPage = ui.tabWidget_response->widget(0); - if (respPage) + // --- Inject sidebar logo (matches HTML .app-logo) --- { - auto *respLayout = qobject_cast(respPage->layout()); - if (respLayout) - respLayout->insertWidget(0, m_labelResponseInfo); + auto *logoWidget = new QWidget(); + logoWidget->setObjectName("sidebarLogo"); + auto *logoLayout = new QHBoxLayout(logoWidget); + logoLayout->setContentsMargins(4, 4, 4, 4); + logoLayout->setSpacing(8); + + auto *logoIcon = new QLabel("QR"); + logoIcon->setObjectName("sidebarLogoIcon"); + logoIcon->setAlignment(Qt::AlignCenter); + logoIcon->setFixedSize(28, 28); + + auto *logoText = new QLabel("Request Tool"); + logoText->setObjectName("sidebarLogoText"); + + logoLayout->addWidget(logoIcon); + logoLayout->addWidget(logoText); + logoLayout->addStretch(); + + // Insert at the top of the sidebar's frame layout + auto *frameLayout = qobject_cast(ui.frame_history->layout()); + if (frameLayout) { + frameLayout->insertWidget(0, logoWidget); + } } + // --- Hide surplus "New Request" button (duplicate of +Req) --- + ui.btn_new_request->setVisible(false); + + // --- Hide "Request History" label (replaced by sidebar tabs) --- + ui.label_history->setVisible(false); + + // --- Inject sidebar tabs (历史 / 收藏夹) --- + { + auto *sidebarTabs = new QWidget(); + sidebarTabs->setObjectName("sidebarTabs"); + auto *tabsLayout = new QHBoxLayout(sidebarTabs); + tabsLayout->setContentsMargins(0, 2, 0, 0); + tabsLayout->setSpacing(0); + + auto *tabHistory = new QPushButton(QString::fromUtf8("\u5386\u53f2")); // 历史 + tabHistory->setObjectName("tabHistory"); + tabHistory->setCheckable(true); + tabHistory->setChecked(true); + tabHistory->setFlat(true); + tabsLayout->addWidget(tabHistory); + + auto *tabCollections = new QPushButton(QString::fromUtf8("\u6536\u85cf\u5939")); // 收藏夹 + tabCollections->setObjectName("tabCollections"); + tabCollections->setCheckable(true); + tabCollections->setFlat(true); + tabsLayout->addWidget(tabCollections); + + tabsLayout->addStretch(); + + auto *frameLayout = qobject_cast(ui.frame_history->layout()); + if (frameLayout) { + // Insert after search box (index 2 = logo(0), history header(1), search(2)) + frameLayout->insertWidget(3, sidebarTabs); + } + + // Toggle between history list and collection tree + connect(tabHistory, &QPushButton::toggled, this, [this, tabCollections](bool checked) { + ui.listWidget_history->setVisible(checked); + if (m_collectionTree) m_collectionTree->setVisible(!checked); + if (m_collectionToolbar) m_collectionToolbar->setVisible(!checked); + if (checked) { tabCollections->setChecked(false); } + }); + connect(tabCollections, &QPushButton::toggled, this, [this, tabHistory](bool checked) { + ui.listWidget_history->setVisible(!checked); + if (m_collectionTree) m_collectionTree->setVisible(checked); + if (m_collectionToolbar) m_collectionToolbar->setVisible(checked); + if (checked) { tabHistory->setChecked(false); } + }); + } + + // Response info bar — styling handled by QSS (will be moved above tabWidget later) + m_labelResponseInfo = new QLabel("Status: -- | Time: -- | Received: -- | Sent: --"); + m_labelResponseInfo->setObjectName("m_labelResponseInfo"); + // Don't insert into Body tab — we'll restructure after buildResponseToolbar() + ui.table_body->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); ui.stackedWidget_body->setCurrentWidget(ui.page_raw); @@ -190,6 +264,19 @@ void NetworkRequestTool::initializeUI() // --- Collection panel (M4) --- buildCollectionPanel(); + + // --- Initial sidebar tab state: "历史" active, collection hidden --- + if (m_collectionTree) m_collectionTree->setVisible(false); + if (m_collectionToolbar) m_collectionToolbar->setVisible(false); + + // --- Branded status bar (always-visible footer) --- + { + auto *permanentLabel = new QLabel("Ready | Environment: None | Ctrl+Enter to Send"); + permanentLabel->setObjectName("statusBarLabel"); + statusBar()->addPermanentWidget(permanentLabel); + // Also call showMessage for tooltip-style hints + statusBar()->showMessage("Welcome to Qt Request Tool"); + } } void NetworkRequestTool::initializeConnections() @@ -562,6 +649,16 @@ void NetworkRequestTool::onSendRequest() return; } + // Validate auth: warn if a non-None type is selected but credentials are empty + AuthConfig authCfg = buildAuthConfig(); + if (m_settings.authType != "None" && authCfg.type == AuthType::None) + { + QMessageBox::warning(this, "Warning", + QString("Authorization type '%1' is selected but required credentials are empty.\n" + "The request will be sent without authentication.") + .arg(m_settings.authType)); + } + applyAuthHeader(); // preview header write-back (Approach A) std::unique_ptr req = buildRequestContext(); @@ -570,6 +667,10 @@ void NetworkRequestTool::onSendRequest() std::shared_ptr pReply = NetworkRequestManager::globalInstance()->postRequest(std::move(req)); if (pReply) { + // Store task id for abort + const TaskData *taskData = pReply->task(); + m_currentTaskId = taskData ? taskData->id : 0; + connect(pReply.get(), &NetworkReply::requestFinished, this, &NetworkRequestTool::onResponse); @@ -578,6 +679,10 @@ void NetworkRequestTool::onSendRequest() appendToResponseBody("URL: " + raw + "\n", QColor(204, 204, 204)); appendToResponseBody("Method: " + currentMethod + "\n\n", QColor(204, 204, 204)); } + else + { + m_currentTaskId = 0; + } } RequestType NetworkRequestTool::getRequestType() @@ -792,6 +897,8 @@ std::unique_ptr NetworkRequestTool::buildRequestContext() void NetworkRequestTool::onResponse(QSharedPointer rsp) { + m_currentTaskId = 0; // request completed, clear task id + // Safety check to prevent crashes during object destruction if (!ui.textEdit_response_body || !ui.textEdit_response_headers) { @@ -1036,6 +1143,11 @@ void NetworkRequestTool::onRemoveHeader() void NetworkRequestTool::onAbortTask() { + if (m_currentTaskId != 0) + { + NetworkRequestManager::globalInstance()->stopRequest(m_currentTaskId); + m_currentTaskId = 0; + } } void NetworkRequestTool::onAbortAllTask() @@ -1648,15 +1760,37 @@ void NetworkRequestTool::onSettingsClicked() void NetworkRequestTool::applyAuthHeader() { + // Resolve environment variables for auth credentials so {{var}} placeholders + // are expanded before writing the Authorization header. + const QMap env = m_envStore.activeVariables(); + auto resolve = [&env](const QString &value) -> QString { + if (env.isEmpty() || value.isEmpty()) + return value; + // Simple {{key}} substitution matching the library's regex + static const QRegularExpression re(QStringLiteral("\\{\\{(\\w[\\w.-]*)\\}\\}")); + QString result = value; + QRegularExpressionMatchIterator it = re.globalMatch(value); + while (it.hasNext()) + { + QRegularExpressionMatch m = it.next(); + const QString key = m.captured(1); + if (env.contains(key)) + result.replace(m.captured(0), env.value(key)); + } + return result; + }; + if (m_settings.authType == "Basic" && !m_settings.authUsername.isEmpty()) { - QString creds = m_settings.authUsername + ":" + m_settings.authPassword; + QString user = resolve(m_settings.authUsername); + QString pass = resolve(m_settings.authPassword); + QString creds = user + ":" + pass; QString encoded = creds.toUtf8().toBase64(); updateHeader("Authorization", "Basic " + encoded); } else if (m_settings.authType == "Bearer" && !m_settings.authToken.isEmpty()) { - updateHeader("Authorization", "Bearer " + m_settings.authToken); + updateHeader("Authorization", "Bearer " + resolve(m_settings.authToken)); } else if (m_settings.authType == "ApiKey") { @@ -1664,7 +1798,7 @@ void NetworkRequestTool::applyAuthHeader() // it is already present). Query placement: do NOT write to headers, the // library appends it to the URL query instead. if (m_settings.authApiLocation != "Query" && !m_settings.authApiKey.isEmpty()) - updateHeader(m_settings.authApiKey, m_settings.authApiValue); + updateHeader(resolve(m_settings.authApiKey), resolve(m_settings.authApiValue)); } } @@ -2163,55 +2297,135 @@ void NetworkRequestTool::loadRequestFromJson(const QJsonObject &obj) void NetworkRequestTool::buildResponseToolbar() { - // Inject a compact toolbar at the top of the response body tab layout, - // mirroring how m_labelResponseInfo was injected. - auto *respPage = ui.tabWidget_response->widget(0); // Body tab - if (!respPage) - return; - auto *respLayout = qobject_cast(respPage->layout()); - if (!respLayout) + // --- Restructure: wrap tabWidget_response in a container with status bar + toolbar above --- + // This fixes the bug where status bar and toolbar were inside the Body tab + // and disappeared when switching to Headers/Cookies. + QWidget *oldParent = ui.tabWidget_response->parentWidget(); + if (!oldParent) return; + // Find the splitter layout to figure out which index the tabWidget is at + QSplitter *parentSplitter = qobject_cast(oldParent); + int oldIndexInSplitter = -1; + if (parentSplitter) { + oldIndexInSplitter = parentSplitter->indexOf(ui.tabWidget_response); + } + + // Detach tabWidget_response from its current parent + ui.tabWidget_response->setParent(nullptr); + + // Create a new container widget with VBoxLayout + auto *respContainer = new QWidget(); + respContainer->setObjectName("responseContainer"); + auto *respLayout = new QVBoxLayout(respContainer); + respLayout->setContentsMargins(0, 0, 0, 0); + respLayout->setSpacing(0); + + // Build the response toolbar widget m_responseToolbar = new QWidget(); + m_responseToolbar->setObjectName("responseToolbar"); auto *hLayout = new QHBoxLayout(m_responseToolbar); - hLayout->setContentsMargins(4, 2, 4, 2); + hLayout->setContentsMargins(8, 4, 8, 4); hLayout->setSpacing(4); + // --- LEFT: Tabs (Body/Headers/Cookies) --- + auto *tabGroup = new QButtonGroup(this); + tabGroup->setExclusive(true); + + auto *btnTabBody = new QPushButton("Body"); + btnTabBody->setObjectName("tabResp"); + btnTabBody->setCheckable(true); + btnTabBody->setChecked(true); + btnTabBody->setFlat(true); + tabGroup->addButton(btnTabBody, 0); + hLayout->addWidget(btnTabBody); + + auto *btnTabHeaders = new QPushButton("Headers"); + btnTabHeaders->setObjectName("tabResp"); + btnTabHeaders->setCheckable(true); + btnTabHeaders->setFlat(true); + tabGroup->addButton(btnTabHeaders, 1); + hLayout->addWidget(btnTabHeaders); + + auto *btnTabCookies = new QPushButton("Cookies"); + btnTabCookies->setObjectName("tabResp"); + btnTabCookies->setCheckable(true); + btnTabCookies->setFlat(true); + tabGroup->addButton(btnTabCookies, 2); + hLayout->addWidget(btnTabCookies); + + // --- Spacer pushes everything else to the RIGHT --- + hLayout->addStretch(); + + // --- RIGHT: Search + arrow buttons + Copy/Save/Pretty --- m_leResponseSearch = new QLineEdit(); + m_leResponseSearch->setObjectName("leResponseSearch"); m_leResponseSearch->setPlaceholderText("Search response..."); m_leResponseSearch->setClearButtonEnabled(true); m_leResponseSearch->setMaximumWidth(200); hLayout->addWidget(m_leResponseSearch); auto *btnPrev = new QPushButton("<"); - btnPrev->setFixedWidth(24); + btnPrev->setObjectName("btn_resp_nav"); + btnPrev->setFixedSize(24, 24); btnPrev->setToolTip("Previous match"); hLayout->addWidget(btnPrev); auto *btnNext = new QPushButton(">"); - btnNext->setFixedWidth(24); + btnNext->setObjectName("btn_resp_nav"); + btnNext->setFixedSize(24, 24); btnNext->setToolTip("Next match"); hLayout->addWidget(btnNext); - hLayout->addStretch(); - + // --- RIGHT: Copy / Save / Pretty --- auto *btnCopy = new QPushButton("Copy"); + btnCopy->setObjectName("btn_resp_secondary"); btnCopy->setToolTip("Copy response body to clipboard"); hLayout->addWidget(btnCopy); auto *btnSave = new QPushButton("Save"); + btnSave->setObjectName("btn_resp_secondary"); btnSave->setToolTip("Save response body to file"); hLayout->addWidget(btnSave); auto *chkPretty = new QCheckBox("Pretty"); + chkPretty->setObjectName("chk_resp_pretty"); chkPretty->setChecked(true); chkPretty->setToolTip("Toggle between pretty-printed and raw JSON"); hLayout->addWidget(chkPretty); - // Insert after m_labelResponseInfo (index 0) - respLayout->insertWidget(1, m_responseToolbar); + // Add to container: status bar (top) → toolbar → tabWidget (bottom) + if (m_labelResponseInfo) respLayout->addWidget(m_labelResponseInfo); + respLayout->addWidget(m_responseToolbar); + respLayout->addWidget(ui.tabWidget_response, /*stretch*/ 1); + + // Insert the new container back into the splitter at the original position + if (parentSplitter && oldIndexInSplitter >= 0) { + parentSplitter->insertWidget(oldIndexInSplitter, respContainer); + } else if (auto *layout = qobject_cast(oldParent->layout())) { + layout->addWidget(respContainer); + } else { + oldParent->layout() ? oldParent->layout()->addWidget(respContainer) + : respContainer->show(); + } + + // Hide the QTabWidget's built-in tab bar (we use our own in the toolbar) + ui.tabWidget_response->tabBar()->hide(); + + // Connect tab buttons to switch QTabWidget page + connect(btnTabBody, &QPushButton::toggled, this, [this](bool c){ if (c) ui.tabWidget_response->setCurrentIndex(0); }); + connect(btnTabHeaders, &QPushButton::toggled, this, [this](bool c){ if (c) ui.tabWidget_response->setCurrentIndex(1); }); + connect(btnTabCookies, &QPushButton::toggled, this, [this](bool c){ if (c) ui.tabWidget_response->setCurrentIndex(2); }); + + // Sync QTabWidget to toolbar (when something else changes current page) + connect(ui.tabWidget_response, &QTabWidget::currentChanged, this, + [btnTabBody, btnTabHeaders, btnTabCookies](int idx) { + QPushButton *target = (idx == 0) ? btnTabBody : (idx == 1) ? btnTabHeaders : btnTabCookies; + if (!target->isChecked()) + target->setChecked(true); + }); - // Connections + // Connections for search, copy, save, pretty connect(m_leResponseSearch, &QLineEdit::textChanged, this, &NetworkRequestTool::onResponseSearchChanged); connect(m_leResponseSearch, &QLineEdit::returnPressed, @@ -2354,45 +2568,53 @@ void NetworkRequestTool::buildCollectionPanel() // ── Toolbar ── auto *collToolbar = new QWidget(); + collToolbar->setObjectName("collectionToolbar"); auto *tbLayout = new QHBoxLayout(collToolbar); tbLayout->setContentsMargins(0, 4, 0, 2); - tbLayout->setSpacing(2); + tbLayout->setSpacing(4); auto *btnNewColl = new QPushButton("+Coll"); + btnNewColl->setObjectName("btn_coll"); btnNewColl->setToolTip("New collection"); - btnNewColl->setFixedHeight(22); + btnNewColl->setFixedHeight(24); tbLayout->addWidget(btnNewColl); auto *btnAddFolder = new QPushButton("+Folder"); + btnAddFolder->setObjectName("btn_coll"); btnAddFolder->setToolTip("Add folder"); - btnAddFolder->setFixedHeight(22); + btnAddFolder->setFixedHeight(24); tbLayout->addWidget(btnAddFolder); auto *btnAddReq = new QPushButton("+Req"); + btnAddReq->setObjectName("btn_coll"); btnAddReq->setToolTip("Add current request to collection"); - btnAddReq->setFixedHeight(22); + btnAddReq->setFixedHeight(24); tbLayout->addWidget(btnAddReq); tbLayout->addStretch(); auto *btnImport = new QPushButton("Import"); + btnImport->setObjectName("btn_coll_secondary"); btnImport->setToolTip("Import Postman v2.1 collection"); - btnImport->setFixedHeight(22); + btnImport->setFixedHeight(24); tbLayout->addWidget(btnImport); auto *btnExport = new QPushButton("Export"); + btnExport->setObjectName("btn_coll_secondary"); btnExport->setToolTip("Export to Postman v2.1"); - btnExport->setFixedHeight(22); + btnExport->setFixedHeight(24); tbLayout->addWidget(btnExport); // Insert toolbar before the tree int insertIdx = parentLayout->indexOf(ui.listWidget_history); if (insertIdx < 0) insertIdx = parentLayout->count(); + m_collectionToolbar = collToolbar; parentLayout->insertWidget(insertIdx, collToolbar); // ── Tree ── m_collectionTree = new QTreeWidget(); + m_collectionTree->setObjectName("collectionTree"); m_collectionTree->setHeaderHidden(true); m_collectionTree->setRootIsDecorated(true); m_collectionTree->setMinimumHeight(100); @@ -2496,7 +2718,7 @@ void NetworkRequestTool::onAddCollectionRequest() QString name = QInputDialog::getText(this, "Add Request", "Name:", QLineEdit::Normal, ui.lineEdit_url->text().isEmpty() - ? "New Request" : baseUrlFromInput(), &ok); + ? QStringLiteral("Untitled Request") : baseUrlFromInput(), &ok); if (!ok || name.isEmpty()) return; diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 648ca4e..2622821 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -205,9 +205,11 @@ private slots: QList m_searchSelections; int m_currentSearchHit{-1}; QString m_binaryFilePath; + quint64 m_currentTaskId{0}; // track the running request for abort // Collection (M4) QTreeWidget *m_collectionTree{nullptr}; + QWidget *m_collectionToolbar{nullptr}; Collection m_collection; // in-memory collection model QString m_collectionPath; // file path for persistence }; diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index fdc7ddc..fd09a4a 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -15,10 +15,16 @@ #include #include #include +#include +#include +#include +#include #include "test_qtrequester.h" #include "networkrequesttool.h" #include "requestcontext.h" #include "environmentstore.h" +#include "collectionmodel.h" +#include "postmanconverter.h" #include "responseresult.h" #include "networkrequestmanager.h" #include "networkreply.h" @@ -153,6 +159,23 @@ void TestQtRequester::testApplyAuthHeader() QByteArray expected = QByteArray("user1:pass1").toBase64(); QCOMPARE(headers.value("Authorization"), QByteArray("Basic ") + expected); + // FIXED: Environment variable expansion in auth credentials. + // When an env variable is set, {{var}} placeholders in auth values are resolved. + tool.ui.table_headers->setRowCount(0); + QMap envVars; + envVars.insert("token", "resolved-token-456"); + tool.m_envStore.upsert("Test", envVars); + tool.m_envStore.setActiveName("Test"); + + tool.m_settings.authType = "Bearer"; + tool.m_settings.authToken = "{{token}}"; + tool.applyAuthHeader(); + headers = tool.getHeaders(); + QCOMPARE(headers.value("Authorization"), QByteArray("Bearer resolved-token-456")); + + // Cleanup + tool.m_envStore.setActiveName(""); + // None should not add header tool.ui.table_headers->setRowCount(0); tool.m_settings.authType = "None"; @@ -625,3 +648,583 @@ void TestQtRequester::testResponseSearch() QVERIFY(tool.m_currentSearchHit == firstHit); // back to first } +// =========================================================================== +// Stage D: comprehensive real-user UI interaction coverage +// =========================================================================== + +// --------------------------------------------------------------------------- +// D1. Request history: save -> replay (loadFromHistory) -> search filter +// --------------------------------------------------------------------------- +void TestQtRequester::testHistorySaveAndReplay() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + auto *methodCombo = tool.findChild("cmb_method"); + QVERIFY(methodCombo); + auto *bodyEdit = tool.ui.textEdit_body; + auto *headersTable = tool.findChild("table_headers"); + QVERIFY(headersTable); + + // --- Simulate a user filling a request form --- + methodCombo->setCurrentText("POST"); + urlEdit->setText("https://example.com/api/login"); + bodyEdit->setPlainText("{\"user\":\"alice\"}"); + headersTable->setRowCount(1); + headersTable->setItem(0, 0, new QTableWidgetItem("X-Token")); + headersTable->setItem(0, 1, new QTableWidgetItem("abc123")); + + // --- Trigger "Save" (writes to in-memory history) --- + tool.saveToHistory(); + QCOMPARE(tool.requestHistory.size(), 1); + QCOMPARE(tool.ui.listWidget_history->count(), 1); + + // --- Now mutate the form to a different state --- + urlEdit->setText("https://example.com/other"); + bodyEdit->setPlainText("garbage"); + headersTable->setRowCount(0); + + // --- Replay the saved history item (real user clicks the list item) --- + QListWidgetItem *item = tool.ui.listWidget_history->item(0); + tool.onHistoryItemClicked(item); + QTest::qWait(20); + + // --- Assert the form was restored from history --- + QCOMPARE(urlEdit->text(), QString("https://example.com/api/login")); + QCOMPARE(bodyEdit->toPlainText(), QString("{\"user\":\"alice\"}")); + QCOMPARE(headersTable->rowCount(), 1); + QCOMPARE(headersTable->item(0, 0)->text(), QString("X-Token")); + QCOMPARE(headersTable->item(0, 1)->text(), QString("abc123")); + QCOMPARE(methodCombo->currentText(), QString("POST")); +} + +void TestQtRequester::testHistorySearchFilter() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + + // Add three distinct history entries + const QStringList urls = { + "https://api.users.com/list", + "https://api.orders.com/create", + "https://api.users.com/delete"}; + for (const QString &u : urls) + { + urlEdit->setText(u); + tool.saveToHistory(); + } + QCOMPARE(tool.ui.listWidget_history->count(), 3); + + // Filter by "users" -> only the two users.com entries remain visible + tool.onSearchHistory("users"); + QTest::qWait(10); + int visible = 0; + for (int i = 0; i < tool.ui.listWidget_history->count(); ++i) + if (!tool.ui.listWidget_history->item(i)->isHidden()) + ++visible; + QCOMPARE(visible, 2); + + // Clear filter -> all visible again + tool.onSearchHistory(""); + QTest::qWait(10); + visible = 0; + for (int i = 0; i < tool.ui.listWidget_history->count(); ++i) + if (!tool.ui.listWidget_history->item(i)->isHidden()) + ++visible; + QCOMPARE(visible, 3); +} + +void TestQtRequester::testHistoryEmptyAndOverflow() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + + // Empty history: clicking should be a no-op and not crash + QCOMPARE(tool.requestHistory.size(), 0); + tool.onHistoryItemClicked(nullptr); + QTest::qWait(10); + + // Overflow: save > 100 entries, list must cap at 100 + for (int i = 0; i < 120; ++i) + { + urlEdit->setText(QString("https://host/%1").arg(i)); + tool.saveToHistory(); + } + QCOMPARE(tool.requestHistory.size(), 100); + QCOMPARE(tool.ui.listWidget_history->count(), 100); + + // Most recent entry (prepend) is the last saved one + QCOMPARE(tool.requestHistory.first().url, QString("https://host/119")); +} + +// --------------------------------------------------------------------------- +// D2. Environment variable {{var}} expansion in URL (mock-verified) +// --------------------------------------------------------------------------- +void TestQtRequester::testEnvVariableExpansionInUrl() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Configure an environment "Dev" with {{host}} -> a concrete base, then activate it. + QMap devVars; + devVars.insert("host", "https://api.dev.example.com"); + tool.m_envStore.upsert("Dev", devVars); + tool.m_envStore.setActiveName("Dev"); + tool.populateEnvironmentCombo(); + QVERIFY(tool.m_cmbEnvironment); + tool.m_cmbEnvironment->setCurrentText("Dev"); + QTest::qWait(10); + QCOMPARE(tool.m_envStore.activeVariables().value("host"), QString("https://api.dev.example.com")); + + // Real-user path: type a URL containing the placeholder. + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText("{{host}}/echo"); + + // At the UI level the literal placeholder remains in the edit box. + QCOMPARE(urlEdit->text(), QString("{{host}}/echo")); + + // The active environment must be carried into the request context so the + // underlying pipeline can perform {{host}} substitution at send time. + std::unique_ptr ctx = tool.buildRequestContext(); + QVERIFY(ctx); + QCOMPARE(ctx->environment.value("host"), QString("https://api.dev.example.com")); + // UI-level url still carries the unresolved placeholder (resolved downstream by the lib). + QString decodedUrl = QUrl::fromPercentEncoding(ctx->url.toUtf8()); + QVERIFY2(decodedUrl.contains("{{host}}"), + qPrintable(QString("Expected unresolved placeholder in ctx->url, got: %1").arg(decodedUrl))); + + // Switching back to no active environment must clear the active variables. + tool.m_envStore.setActiveName(""); + QTest::qWait(10); + QVERIFY(tool.m_envStore.activeVariables().isEmpty()); +} + +// --------------------------------------------------------------------------- +// D3. Auth: OAuth2 all grants + ApiKey query injection + empty-credential fallback +// --------------------------------------------------------------------------- +void TestQtRequester::testBuildAuthConfigOAuth2AllGrants() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Client Credentials + tool.m_settings.authType = "OAuth2"; + tool.m_settings.oauthGrantType = "ClientCredentials"; + tool.m_settings.oauthTokenUrl = "https://auth/token"; + tool.m_settings.oauthClientId = "cid"; + tool.m_settings.oauthClientSecret = "secret"; + tool.m_settings.oauthScopes = "read write"; + { + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.type), int(AuthType::OAuth2)); + QCOMPARE(int(cfg.oauth2Config.grant), int(OAuth2GrantType::ClientCredentials)); + QCOMPARE(cfg.oauth2Config.tokenUrl, QString("https://auth/token")); + QVERIFY(cfg.isValid()); + } + + // Password grant requires username/password + tool.m_settings.oauthGrantType = "Password"; + tool.m_settings.oauthUsername = "bob"; + tool.m_settings.oauthPassword = "pw"; + { + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.oauth2Config.grant), int(OAuth2GrantType::Password)); + QCOMPARE(cfg.oauth2Config.username, QString("bob")); + QCOMPARE(cfg.oauth2Config.password, QString("pw")); + QVERIFY(cfg.isValid()); + } + + // Refresh token grant + tool.m_settings.oauthGrantType = "Refresh Token"; + tool.m_settings.oauthRefreshToken = "rt-123"; + { + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.oauth2Config.grant), int(OAuth2GrantType::RefreshToken)); + QCOMPARE(cfg.oauth2Config.refreshToken, QString("rt-123")); + QVERIFY(cfg.isValid()); + } + + // OAuth2 token is fetched at runtime and injected then; the UI preview does + // not write a static Authorization header. Assert the config type is correct. + QCOMPARE(int(tool.buildAuthConfig().type), int(AuthType::OAuth2)); +} + +void TestQtRequester::testApiKeyQueryInjection() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + tool.m_settings.authType = "ApiKey"; + tool.m_settings.authApiKey = "X-API-Key"; + tool.m_settings.authApiValue = "secret-value"; + tool.m_settings.authApiLocation = "Query"; // inject as query param + + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.type), int(AuthType::ApiKey)); + QCOMPARE(int(cfg.apiKeyPlacement), int(ApiKeyPlacement::QueryParam)); + QVERIFY(cfg.isValid()); + + // ApiKey-in-query must NOT appear as a header; it is appended to the URL query. + tool.applyAuthHeader(); + QMap headers = tool.getHeaders(); + QVERIFY2(!headers.contains("X-API-Key"), + "ApiKey with Query placement must not be added as a header"); + + // Build the full URL and confirm the key shows up in the query string. + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText("https://api/resource"); + QString full = tool.buildUrlWithParams(); + // Note: query-placement injection happens in the request pipeline; at the UI + // level buildUrlWithParams only appends user-defined params, so we assert the + // auth config itself correctly carries the placement strategy. + QCOMPARE(cfg.apiKeyPlacement, ApiKeyPlacement::QueryParam); +} + +void TestQtRequester::testAuthEmptyCredentialFallback() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Basic with empty username -> buildAuthConfig returns None (silent fallback), + // so no Authorization header is emitted. This documents the current UI behaviour. + tool.m_settings.authType = "Basic"; + tool.m_settings.authUsername = ""; + tool.m_settings.authPassword = ""; + { + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.type), int(AuthType::None)); + tool.applyAuthHeader(); + QVERIFY2(!tool.getHeaders().contains("Authorization"), + "Empty Basic credentials should not emit an Authorization header"); + } + + // Bearer with empty token -> None fallback, no header. + tool.m_settings.authType = "Bearer"; + tool.m_settings.authToken = ""; + { + AuthConfig cfg = tool.buildAuthConfig(); + QCOMPARE(int(cfg.type), int(AuthType::None)); + tool.applyAuthHeader(); + QVERIFY2(!tool.getHeaders().contains("Authorization"), + "Empty Bearer token should not emit an Authorization header"); + } + + // No auth -> no header + tool.m_settings.authType = "None"; + tool.applyAuthHeader(); + QVERIFY2(!tool.getHeaders().contains("Authorization"), + "AuthType::None must not emit an Authorization header"); + + // FIXED: When a non-None auth type is selected but credentials are empty, + // onSendRequest() now warns the user via a QMessageBox. Verify the warning + // is shown and auto-dismissed without crash. + { + tool.m_settings.authType = "Basic"; + tool.m_settings.authUsername = ""; + tool.m_settings.authPassword = ""; + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText("https://example.com/api"); + + // Auto-dismiss the warning dialog + QTimer::singleShot(0, []() { + if (auto *mb = qobject_cast(QApplication::activeModalWidget())) + mb->accept(); + }); + tool.onSendRequest(); + QTest::qWait(50); + // The request should still be sent (without auth), no crash. + } +} + +// --------------------------------------------------------------------------- +// D4. Response formatting: JSON pretty/raw toggle, XML/HTML display, malformed JSON +// --------------------------------------------------------------------------- +void TestQtRequester::testResponsePrettyToggle() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + const QString compact = R"({"name":"alice","age":30,"tags":["a","b"]})"; + tool.m_lastResponseBody = compact; + tool.m_isResponseJson = true; + + // Pretty ON -> indented output + tool.onResponsePrettyToggled(true); + QString pretty = tool.ui.textEdit_response_body->toPlainText(); + QVERIFY2(pretty.contains('\n'), "Pretty JSON should contain newlines"); + QVERIFY2(pretty.contains(" \"name\""), "Pretty JSON should be indented"); + + // Pretty OFF -> original compact text restored verbatim + tool.onResponsePrettyToggled(false); + QCOMPARE(tool.ui.textEdit_response_body->toPlainText(), compact); + + // Non-JSON response -> toggle must be a no-op (no crash, no change) + tool.m_isResponseJson = false; + tool.ui.textEdit_response_body->setPlainText("x"); + tool.onResponsePrettyToggled(true); + QCOMPARE(tool.ui.textEdit_response_body->toPlainText(), QString("x")); +} + +void TestQtRequester::testResponseXmlHtmlDisplay() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // XML response -> isXmlResponse true, displayed as-is. + QMap xmlHeaders; + xmlHeaders.insert("Content-Type", "application/xml"); + QVERIFY(tool.isXmlResponse(xmlHeaders)); + tool.displayJsonResponse("Bob"); // non-JSON path: still sets body + QVERIFY(tool.ui.textEdit_response_body->toPlainText().contains("Bob")); + + // HTML response -> displayed verbatim (no JSON highlighting crash). + QMap htmlHeaders; + htmlHeaders.insert("Content-Type", "text/html"); + QVERIFY(!tool.isJsonResponse(htmlHeaders)); + tool.ui.textEdit_response_body->setPlainText("

Hi

"); + QVERIFY(tool.ui.textEdit_response_body->toPlainText().contains("

Hi

")); +} + +void TestQtRequester::testResponseMalformedJsonTolerance() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Server claims JSON but body is malformed -> must not crash, raw body kept. + tool.m_isResponseJson = true; + const QString broken = R"({"name":"alice", age: })"; // invalid JSON + tool.m_lastResponseBody = broken; + tool.ui.textEdit_response_body->setPlainText(broken); // simulate onResponse showing raw + tool.onResponsePrettyToggled(true); // parse fails -> textEdit unchanged (graceful) + QString out = tool.ui.textEdit_response_body->toPlainText(); + QVERIFY2(!out.isEmpty(), "Malformed JSON must still leave the body visible"); + QVERIFY2(out == broken, "Malformed JSON must keep the original raw body"); +} + +// --------------------------------------------------------------------------- +// D5. Collection management: CRUD + tree replay + Postman v2.1 round-trip +// --------------------------------------------------------------------------- +void TestQtRequester::testCollectionCrudAndReplay() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + QTemporaryDir dir; + QVERIFY(dir.isValid()); + tool.m_collectionPath = dir.filePath("collection.json"); + + // --- Create a folder under root and two requests (real model ops) --- + // Passing an empty parentId targets the implicit root collection node. + QString folderId = tool.m_collection.addFolder("", "My Collection"); + QVERIFY(!folderId.isEmpty()); + QString getUsersId = tool.m_collection.addRequest( + folderId, "GET Users", QJsonDocument::fromJson(R"({"method":"GET","url":"https://api/users"})").object()); + QString postUser = tool.m_collection.addRequest( + folderId, "POST User", QJsonDocument::fromJson(R"({"method":"POST","url":"https://api/users"})").object()); + QVERIFY(!getUsersId.isEmpty()); + QVERIFY(!postUser.isEmpty()); + + tool.populateCollectionTree(); + QVERIFY(tool.m_collectionTree); + QVERIFY(tool.m_collectionTree->topLevelItemCount() >= 1); + + // --- Persist to disk and reload --- + tool.saveCollection(); + QVERIFY(QFile::exists(tool.m_collectionPath)); + Collection reloaded; + QVERIFY(reloaded.load(tool.m_collectionPath)); + CollectionItem *folder = reloaded.findById(folderId); + QVERIFY(folder); + QCOMPARE(folder->children.size(), 2); + + // --- Replay a request node: tree item carries the request id in data(0) --- + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText(""); // clear first + tool.m_collection = reloaded; + + QTreeWidgetItem *treeItem = nullptr; + // Locate the tree item whose UserRole data equals getUsersId. + std::function find = [&](QTreeWidgetItem *it) { + if (treeItem) + return; + if (it->data(0, Qt::UserRole).toString() == getUsersId) + treeItem = it; + for (int i = 0; i < it->childCount(); ++i) + find(it->child(i)); + }; + for (int i = 0; i < tool.m_collectionTree->topLevelItemCount(); ++i) + find(tool.m_collectionTree->topLevelItem(i)); + + QVERIFY2(treeItem, "Tree item for GET Users must exist"); + tool.onCollectionItemClicked(treeItem, 0); + QTest::qWait(20); + QVERIFY2(!urlEdit->text().isEmpty(), "Replaying collection request should fill the URL"); +} + +void TestQtRequester::testPostmanImportExportRoundTrip() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Build a collection, export to Postman v2.1 JSON, re-import, and compare. + QString folderId = tool.m_collection.addFolder("", "PostmanTest"); + QVERIFY(!folderId.isEmpty()); + tool.m_collection.addRequest( + folderId, "Ping", QJsonDocument::fromJson(R"({"method":"GET","url":"https://api/ping"})").object()); + + QJsonObject exported = PostmanConverter::toPostmanV21(tool.m_collection); + QVERIFY(exported.contains("info")); + QVERIFY(exported.contains("item")); + + bool ok = false; + Collection imported = PostmanConverter::fromPostmanV21(exported, &ok); + QVERIFY2(ok, "Postman import should succeed"); + // Import regenerates ids, so locate the folder by name under the root. + CollectionItem *folder = nullptr; + for (CollectionItem &child : imported.root().children) + if (child.name == "PostmanTest") + folder = &child; + QVERIFY(folder); + QVERIFY(folder->children.size() >= 1); +} + +// --------------------------------------------------------------------------- +// D6. Exception / boundary: invalid URL, empty send, timeout, abort +// --------------------------------------------------------------------------- +void TestQtRequester::testInvalidUrlSendHandling() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + + // Helper: auto-dismiss any modal QMessageBox the UI may pop for invalid input, + // so the test does not block on a native dialog in headless/CI runs. + auto autoCloseMsgBox = []() { + QTimer::singleShot(0, []() { + if (auto *mb = qobject_cast(QApplication::activeModalWidget())) + mb->accept(); + }); + }; + + // Invalid URL -> onSendRequest returns early, no reply created, no crash. + urlEdit->setText("this is not a url"); + autoCloseMsgBox(); + tool.onSendRequest(); + QTest::qWait(50); + // Response area should not contain a successful "Sending request..." block. + QString body = tool.ui.textEdit_response_body->toPlainText(); + QVERIFY2(!body.contains("URL: this is not a url"), + "Invalid URL must not produce a send attempt"); + + // Empty URL -> also rejected. + urlEdit->setText(""); + autoCloseMsgBox(); + tool.onSendRequest(); + QTest::qWait(20); +} + +void TestQtRequester::testAbortRunningRequest() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + HttpTestServer srv; + QVERIFY(srv.start()); + + // Start a slow request that we can abort mid-flight + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText(srv.baseUrl() + "/slowbytes/100000"); // ~640ms streaming + + // Send the request — this will populate m_currentTaskId + tool.onSendRequest(); + QTest::qWait(100); // let the request start running + + // Verify that m_currentTaskId is non-zero (a request is in flight) + QVERIFY2(tool.m_currentTaskId != 0, "A running request must have a non-zero task id"); + + // Abort the running task + tool.onAbortTask(); + QTest::qWait(100); + + // After abort, task id must be cleared (either via onAbortTask or onResponse) + QVERIFY2(tool.m_currentTaskId == 0, "Task id must be cleared after abort"); + + // Calling abort again with no active task must not crash + tool.onAbortTask(); + + srv.stop(); +} + +void TestQtRequester::testTransferTimeoutHandling() +{ + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // A small transfer timeout should be honoured by the request pipeline. + // /slowbytes streams ~640ms total; a 300ms timeout must abort it (not hang). + tool.m_settings.transferTimeoutMs = 300; + + HttpTestServer srv; + QVERIFY(srv.start()); + auto *urlEdit = tool.findChild("lineEdit_url"); + QVERIFY(urlEdit); + urlEdit->setText(srv.baseUrl() + "/slowbytes/100000"); + + tool.onSendRequest(); + // Wait well past the timeout; the request must not hang/crash the UI, and the + // pipeline must surface a timeout/error into the response area (not silently hang). + QTest::qWait(2000); + QString resp = tool.ui.textEdit_response_body->toPlainText(); + QVERIFY2(!resp.isEmpty(), + "After transfer timeout the UI must show an error/timeout response, not stay blank"); +} + +// --------------------------------------------------------------------------- +// D7. Batch execution (UI not yet implemented) — placeholder / design marker +// --------------------------------------------------------------------------- +void TestQtRequester::testBatchExecutionPlaceholder() +{ + // The qtrequester UI does not yet expose a "run collection / batch send" entry + // point (no runCollection / batchRun / runAllTask symbols exist). This test + // documents the intended coverage and is skipped until the feature lands. + // + // When implemented, the following real-user paths must be covered: + // 1. Select a collection or folder node in m_collectionTree. + // 2. Trigger batch send (button / context menu). + // 3. Requests execute concurrently or serially per setting; assert each + // child response is captured and the history grows by N entries. + // 4. A failing child request is isolated (others still run) — no global abort. + // 5. Progress/summary UI reflects N/M completed. + QSKIP("批量请求执行 UI 尚未实现(Batch execution UI not yet implemented)"); +} + diff --git a/test/test_qtrequester.h b/test/test_qtrequester.h index b5caca8..c221248 100644 --- a/test/test_qtrequester.h +++ b/test/test_qtrequester.h @@ -42,6 +42,38 @@ private slots: // M4 auth save/load + M3 search void testSaveAndLoadDiskWithAuth(); void testResponseSearch(); + + // ---- Stage D: comprehensive real-user UI interaction coverage ---- + + // Request history: save / replay / search / empty + overflow boundary + void testHistorySaveAndReplay(); + void testHistorySearchFilter(); + void testHistoryEmptyAndOverflow(); + + // Environment variable {{var}} expansion in URL (mock-verified) + void testEnvVariableExpansionInUrl(); + + // Auth: OAuth2 all grants + ApiKey query injection + empty-credential fallback + void testBuildAuthConfigOAuth2AllGrants(); + void testApiKeyQueryInjection(); + void testAuthEmptyCredentialFallback(); + + // Response formatting: JSON pretty/raw toggle, XML/HTML display, malformed JSON tolerance + void testResponsePrettyToggle(); + void testResponseXmlHtmlDisplay(); + void testResponseMalformedJsonTolerance(); + + // Collection management: CRUD + tree replay + Postman v2.1 round-trip + special chars + void testCollectionCrudAndReplay(); + void testPostmanImportExportRoundTrip(); + + // Exception / boundary: invalid URL, empty send, timeout, abort + void testInvalidUrlSendHandling(); + void testAbortRunningRequest(); + void testTransferTimeoutHandling(); + + // Batch execution (UI not yet implemented) — placeholder + void testBatchExecutionPlaceholder(); }; #endif From 8c28b63c8c7c378f2ff5b801fce23270042c3a99 Mon Sep 17 00:00:00 2001 From: vilas Date: Thu, 6 Aug 2026 14:02:26 +0800 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=E6=8C=89=E7=85=A7HTML=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E4=BC=98=E5=8C=96Downloader=E5=92=8CRequester?= =?UTF-8?q?=E7=9A=84UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 precision-dark/light QSS 主题样式 - 新增 ThemeManager 实现主题切换功能 - 重构 NetworkDownloaderMainWindow UI 布局 - 重构 NetworkRequestTool UI 布局 - 更新 downloadermainwindow/networkrequesttool 代码适配新UI - 新增 UI 相关单元测试 (TestDownloaderUi, TestTheme) --- samples/CMakeLists.txt | 2 + .../networkdownloader/NetworkDownloader.pro | 3 +- .../networkdownloader/NetworkDownloader.qrc | 6 +- .../NetworkDownloaderMainWindow.ui | 794 +------------- .../downloadermainwindow.cpp | 69 ++ .../networkdownloader/downloadermainwindow.h | 10 + samples/networkdownloader/main.cpp | 5 + .../resources/styles/precision-dark.qss | 710 +++++++++++++ .../resources/styles/precision-light.qss | 780 ++++++++++++++ samples/networkdownloader/thememanager.h | 153 +++ .../networkrequesttool/NetworkRequestTool.qrc | 4 + .../networkrequesttool/NetworkRequestTool.ui | 998 ------------------ samples/networkrequesttool/main.cpp | 6 + .../networkrequesttool/networkrequesttool.cpp | 46 +- .../networkrequesttool/networkrequesttool.h | 7 + .../networkrequesttool/networkrequesttool.pro | 3 +- .../resources/styles/precision-dark.qss | 710 +++++++++++++ .../resources/styles/precision-light.qss | 780 ++++++++++++++ samples/networkrequesttool/thememanager.h | 153 +++ test/CMakeLists.txt | 85 ++ test/main_downloaderui.cpp | 17 + test/main_theme.cpp | 17 + test/main_uitester.cpp | 5 + test/test_downloaderui.cpp | 224 ++++ test/test_downloaderui.h | 34 + test/test_theme.cpp | 293 +++++ test/test_theme.h | 41 + 27 files changed, 4192 insertions(+), 1763 deletions(-) create mode 100644 samples/networkdownloader/resources/styles/precision-dark.qss create mode 100644 samples/networkdownloader/resources/styles/precision-light.qss create mode 100644 samples/networkdownloader/thememanager.h create mode 100644 samples/networkrequesttool/resources/styles/precision-dark.qss create mode 100644 samples/networkrequesttool/resources/styles/precision-light.qss create mode 100644 samples/networkrequesttool/thememanager.h create mode 100644 test/main_downloaderui.cpp create mode 100644 test/main_theme.cpp create mode 100644 test/test_downloaderui.cpp create mode 100644 test/test_downloaderui.h create mode 100644 test/test_theme.cpp create mode 100644 test/test_theme.h diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index dfac273..8cb7d2a 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(QtRequester ${GUI_EXECUTABLE_FLAG} networkrequesttool/networkrequesttool.h networkrequesttool/jsonsyntaxhighlighter.h networkrequesttool/xmlsyntaxhighlighter.h + networkrequesttool/thememanager.h # UI / Resources networkrequesttool/NetworkRequestTool.ui @@ -64,6 +65,7 @@ add_executable(QtDownloader ${GUI_EXECUTABLE_FLAG} networkdownloader/downloadtaskmodel.h networkdownloader/downloadmanager.h networkdownloader/downloadtask.h + networkdownloader/thememanager.h # UI / Resources networkdownloader/NetworkDownloaderMainWindow.ui diff --git a/samples/networkdownloader/NetworkDownloader.pro b/samples/networkdownloader/NetworkDownloader.pro index 276dfd4..6aa0b58 100644 --- a/samples/networkdownloader/NetworkDownloader.pro +++ b/samples/networkdownloader/NetworkDownloader.pro @@ -24,7 +24,8 @@ HEADERS += \ downloadermainwindow.h \ downloadtaskmodel.h \ downloadmanager.h \ - downloadtask.h + downloadtask.h \ + thememanager.h FORMS += \ NetworkDownloaderMainWindow.ui diff --git a/samples/networkdownloader/NetworkDownloader.qrc b/samples/networkdownloader/NetworkDownloader.qrc index 942be68..f74efbb 100644 --- a/samples/networkdownloader/NetworkDownloader.qrc +++ b/samples/networkdownloader/NetworkDownloader.qrc @@ -2,4 +2,8 @@ resources/app.ico - \ No newline at end of file + + resources/styles/precision-light.qss + resources/styles/precision-dark.qss + + diff --git a/samples/networkdownloader/NetworkDownloaderMainWindow.ui b/samples/networkdownloader/NetworkDownloaderMainWindow.ui index 267be80..b39e185 100644 --- a/samples/networkdownloader/NetworkDownloaderMainWindow.ui +++ b/samples/networkdownloader/NetworkDownloaderMainWindow.ui @@ -19,748 +19,6 @@ Qt Downloader - - /* VSCode-Inspired Professional Dark Theme - Qt5 Compatible */ -/* ======================================== - DESIGN SYSTEM CONSTANTS - ======================================== */ - -/* Color Definitions (Qt5 compatible) */ -/* Background Colors */ -/* BG Primary: #1e1e1e, BG Secondary: #252526, BG Tertiary: #2d2d30 */ -/* Border Colors: Light #3e3e42, Medium #4e4e52 */ -/* Text Colors: Primary #cccccc, Secondary #969696, Disabled #6a6a6a */ -/* Accent Colors: Blue #0078d4, Green #107c10, Orange #ff8c00, Red #e81123 */ -/* State Colors: Hover #106ebe, Pressed #005a9e */ - -/* ======================================== - GLOBAL STYLES - ======================================== */ - -QMainWindow { - background-color: #1e1e1e; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 13px; - font-weight: 400; -} - -QWidget#centralWidget { - background-color: #1e1e1e; - color: #cccccc; -} - -/* ======================================== - CONTAINER STYLES - ======================================== */ - -QFrame { - background-color: #252526; - border: 1px solid #3e3e42; - border-radius: 6px; - padding: 0px; -} - -QFrame[frameShape="0"] { - border: none; - background-color: transparent; -} - -/* ======================================== - TYPOGRAPHY SYSTEM - ======================================== */ - -QLabel { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-weight: 400; - background-color: transparent; -} - -QLabel.heading { - color: #ffffff; - font-size: 16px; - font-weight: 600; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - letter-spacing: -0.2px; -} - -QLabel.subheading { - color: #cccccc; - font-size: 14px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -QLabel.small { - font-size: 11px; - color: #969696; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* Status labels with special colors */ -QLabel#labelSpeed { - color: #0078d4; - font-weight: 600; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -QLabel#labelTime { - color: #ff8c00; - font-weight: 600; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -/* ======================================== - INPUT CONTROLS - ======================================== */ - -QLineEdit, QPlainTextEdit, QTextEdit { - border: 1px solid #3e3e42; - border-radius: 4px; - padding: 8px 12px; - background-color: #1e1e1e; - color: #cccccc; - font-size: 13px; - font-family: 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; -} - -QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus { - border: 2px solid #0078d4; - background-color: #1e1e1e; - outline: none; - padding: 7px 11px; /* Compensate for 2px border */ -} - -QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled { - background-color: #2d2d30; - color: #6a6a6a; - border-color: #3e3e42; -} - -QLineEdit::placeholder, QPlainTextEdit::placeholder { - color: #6a6a6a; - font-style: italic; -} - -QLineEdit:hover, QPlainTextEdit:hover, QTextEdit:hover { - border-color: #4e4e52; -} - -QLineEdit:focus:hover, QPlainTextEdit:focus:hover, QTextEdit:focus:hover { - border-color: #0078d4; -} - -/* ======================================== - TABLE VIEW STYLING - ======================================== */ - -QTableView { - border: 1px solid #3e3e42; - border-radius: 6px; - background-color: #252526; - alternate-background-color: #2d2d30; - gridline-color: #3e3e42; - selection-background-color: #0078d4; - selection-color: #ffffff; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - outline: none; -} - -QTableView::item { - padding: 8px 12px; - border-bottom: 1px solid #3e3e42; - color: #cccccc; - min-height: 28px; - background-color: #252526; -} - -QTableView::item:selected { - background-color: #0078d4; - color: #ffffff; -} - -/* Note: Qt5 doesn't support complex pseudo-classes like :!selected and :alternate:hover:!selected */ - -/* ======================================== - TABLE HEADER STYLING - ======================================== */ - -QHeaderView::section { - background-color: #2d2d30; - color: #cccccc; - padding: 10px 12px; - border: none; - border-right: 1px solid #3e3e42; - border-bottom: 2px solid #0078d4; - font-weight: 600; - font-size: 12px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - letter-spacing: 0.2px; - text-transform: uppercase; -} - -QHeaderView::section:first { - border-top-left-radius: 6px; -} - -QHeaderView::section:last { - border-top-right-radius: 6px; - border-right: none; -} - -QHeaderView::section:hover { - background-color: #333337; - color: #ffffff; -} - -QHeaderView::section:pressed { - background-color: #0078d4; - color: #ffffff; -} - -/* ======================================== - BUTTON STYLING SYSTEM - ======================================== */ - -QPushButton { - background-color: #0078d4; - color: #ffffff; - border: none; - border-radius: 6px; - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - min-width: 80px; - min-height: 32px; - letter-spacing: 0.1px; - text-align: center; -} - -QPushButton:hover { - background-color: #106ebe; -} - -QPushButton:pressed { - background-color: #005a9e; -} - -QPushButton:disabled { - background-color: #3e3e42; - color: #6a6a6a; -} - -QPushButton:focus { - border: 2px solid #0078d4; - padding: 6px 14px; -} - -/* Primary Action Buttons */ -QPushButton#btnStart { - background-color: #107c10; - font-weight: 600; -} - -QPushButton#btnStart:hover { - background-color: #128a0f; -} - -QPushButton#btnStart:pressed { - background-color: #0d5e0d; -} - -QPushButton#btnStart:focus { - border: 2px solid #107c10; -} - -/* Danger Action Buttons */ -QPushButton#btnCancel { - background-color: #e81123; - font-weight: 600; -} - -QPushButton#btnCancel:hover { - background-color: #f1707a; -} - -QPushButton#btnCancel:pressed { - background-color: #c50f1f; -} - -QPushButton#btnCancel:focus { - border: 2px solid #e81123; -} - -/* Warning Action Buttons */ -QPushButton#btnDelete { - background-color: #ff8c00; - font-weight: 500; -} - -QPushButton#btnDelete:hover { - background-color: #ffa500; -} - -QPushButton#btnDelete:pressed { - background-color: #e67300; -} - -QPushButton#btnDelete:focus { - border: 2px solid #ff8c00; -} - -/* Primary Add Button */ -QPushButton#btnAddTasks { - background-color: #107c10; - font-weight: 600; -} - -QPushButton#btnAddTasks:hover { - background-color: #128a0f; -} - -QPushButton#btnAddTasks:pressed { - background-color: #0d5e0d; -} - -QPushButton#btnAddTasks:focus { - border: 2px solid #107c10; -} - -/* ======================================== - MENU BAR AND MENU STYLING - ======================================== */ - -QMenuBar { - background-color: #2d2d30; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - border-bottom: 1px solid #3e3e42; -} - -QMenuBar::item { - background-color: transparent; - padding: 8px 16px; - border-radius: 4px; - font-weight: 500; -} - -QMenuBar::item:selected { - background-color: #0078d4; - color: #ffffff; -} - -QMenuBar::item:pressed { - background-color: #005a9e; - color: #ffffff; -} - -QMenu { - background-color: #2d2d30; - border: 1px solid #4e4e52; - border-radius: 6px; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - padding: 4px 0; -} - -QMenu::item { - padding: 8px 20px; - color: #cccccc; - border-radius: 3px; - margin: 1px 8px; - min-height: 20px; -} - -QMenu::item:selected { - background-color: #0078d4; - color: #ffffff; -} - -QMenu::item:disabled { - color: #6a6a6a; -} - -QMenu::separator { - height: 1px; - background-color: #3e3e42; - margin: 4px 12px; -} - -/* ======================================== - STATUS BAR STYLING - ======================================== */ - -QStatusBar { - background-color: #2d2d30; - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - border-top: 1px solid #3e3e42; -} - -QStatusBar::item { - border: none; - padding: 4px 8px; -} - -/* ======================================== - PROGRESS BAR STYLING - ======================================== */ - -QProgressBar { - border: 1px solid #3e3e42; - border-radius: 6px; - text-align: center; - font-weight: 600; - background-color: #1e1e1e; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 12px; - padding: 1px; -} - -QProgressBar::chunk { - background-color: #0078d4; - border-radius: 5px; -} - -/* ======================================== - MODERN SCROLLBAR STYLING - ======================================== */ - -QScrollBar:vertical { - border: none; - background-color: #2d2d30; - width: 12px; - margin: 0; - border-radius: 6px; -} - -QScrollBar::handle:vertical { - background-color: #4e4e52; - border-radius: 6px; - min-height: 24px; - margin: 2px; - border: none; -} - -QScrollBar::handle:vertical:hover { - background-color: #5e5e5e; -} - -QScrollBar::handle:vertical:pressed { - background-color: #0078d4; -} - -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; -} - -QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; -} - -QScrollBar:horizontal { - border: none; - background-color: #2d2d30; - height: 12px; - margin: 0; - border-radius: 6px; -} - -QScrollBar::handle:horizontal { - background-color: #4e4e52; - border-radius: 6px; - min-width: 24px; - margin: 2px; - border: none; -} - -QScrollBar::handle:horizontal:hover { - background-color: #5e5e5e; -} - -QScrollBar::handle:horizontal:pressed { - background-color: #0078d4; -} - -QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - width: 0px; -} - -QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; -} - -/* ======================================== - MODERN DIALOG AND MESSAGE BOX STYLING - ======================================== */ - -QMessageBox { - background-color: #2d2d30; - color: #cccccc; - border: 1px solid #4e4e52; - border-radius: 8px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -QMessageBox QPushButton { - background-color: #0078d4; - color: #ffffff; - border: none; - border-radius: 6px; - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - min-width: 80px; - min-height: 32px; - letter-spacing: 0.1px; -} - -QMessageBox QPushButton:hover { - background-color: #106ebe; -} - -QMessageBox QPushButton:pressed { - background-color: #005a9e; -} - -QMessageBox QLabel { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - background-color: transparent; -} - -QMessageBox QLabel#qt_msgbox_label { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - background-color: transparent; -} - -QMessageBox QLabel#qt_msgboxex_icon_label { - background-color: transparent; -} - -QTextEdit#qt_msgbox_textedit { - background-color: #1e1e1e; - color: #cccccc; - border: 1px solid #3e3e42; - border-radius: 6px; - font-size: 13px; - font-family: 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; -} - -/* ======================================== - SPECIAL COMPONENTS - ======================================== */ - -/* Line separator styling */ -Line { - background-color: #3e3e42; -} - -/* Status frame styling */ -QFrame#statusFrame { - background-color: #2d2d30; - border: 1px solid #3e3e42; - border-radius: 6px; -} - -/* ======================================== - FINAL VISUAL ENHANCEMENTS - ======================================== */ - -/* Focus indicators for all interactive elements */ -QLineEdit:focus, -QPlainTextEdit:focus, -QTextEdit:focus, -QTableView:focus, -QTableView::item:selected { - outline: 2px solid #0078d4; - outline-offset: 1px; -} - -/* Enhanced disabled state styling */ -*:disabled { - color: #6a6a6a; - opacity: 0.7; -} - -/* ======================================== - QDIALOG STYLING - ======================================== */ - -QDialog { - background-color: #1e1e1e; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 13px; -} - -/* ======================================== - GROUP BOX STYLING (matches QFrame) - ======================================== */ - -QGroupBox { - background-color: #252526; - border: 1px solid #3e3e42; - border-radius: 6px; - margin-top: 14px; - padding: 10px 12px 10px 12px; - font-size: 13px; - font-weight: 400; - color: #cccccc; -} - -QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - left: 12px; - top: 0px; - padding: 3px 12px; - color: #ffffff; - font-size: 14px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - background-color: #252526; - border-radius: 4px; -} - -/* ======================================== - CHECKBOX STYLING - ======================================== */ - -QCheckBox { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - spacing: 8px; - background-color: transparent; - padding: 2px 0; -} - -QCheckBox::indicator { - width: 18px; - height: 18px; - border: 1px solid #3e3e42; - border-radius: 3px; - background-color: #1e1e1e; -} - -QCheckBox::indicator:hover { - border-color: #4e4e52; -} - -QCheckBox::indicator:checked { - background-color: #0078d4; - border-color: #0078d4; -} - -QCheckBox::indicator:checked:hover { - background-color: #106ebe; -} - -QCheckBox::indicator:disabled { - background-color: #2d2d30; - border-color: #3e3e42; -} - -/* ======================================== - SPINBOX STYLING (matches QLineEdit) - ======================================== */ - -QSpinBox { - border: 1px solid #3e3e42; - border-radius: 4px; - padding: 8px 12px; - background-color: #1e1e1e; - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; - min-height: 20px; -} - -QSpinBox:focus { - border: 2px solid #0078d4; - padding: 7px 11px; -} - -QSpinBox:hover { - border-color: #4e4e52; -} - -QSpinBox:disabled { - background-color: #2d2d30; - color: #6a6a6a; - border-color: #3e3e42; -} - -QSpinBox::up-button, QSpinBox::down-button { - border: none; - background-color: #2d2d30; - width: 20px; - border-radius: 2px; - margin: 1px; -} - -QSpinBox::up-button:hover, QSpinBox::down-button:hover { - background-color: #3e3e42; -} - -QSpinBox::up-button:pressed { - background-color: #0078d4; -} - -QSpinBox::down-button:pressed { - background-color: #0078d4; -} - -QSpinBox::up-arrow { - image: none; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-bottom: 5px solid #cccccc; -} - -QSpinBox::down-arrow { - image: none; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 5px solid #cccccc; -} - -/* ======================================== - DIALOG BUTTON BOX STYLING - ======================================== */ - -QDialogButtonBox { - background-color: transparent; - button-layout: 2; /* Qt::WindowsLayout */ -} - -QDialogButtonBox QPushButton { - min-width: 90px; -} - @@ -1033,9 +291,6 @@ https://example.com/file2.zip - - color: #0078d4; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; - Speed: -- B/s @@ -1043,9 +298,6 @@ https://example.com/file2.zip - - background: #3e3e42; - Qt::Vertical @@ -1053,9 +305,6 @@ https://example.com/file2.zip - - color: #ff8c00; font-weight: 600; font-family: 'Segoe UI', Arial, sans-serif; - Time: -- @@ -1086,6 +335,16 @@ https://example.com/file2.zip + + + View + + + + + + + Help @@ -1093,6 +352,7 @@ https://example.com/file2.zip + @@ -1117,6 +377,38 @@ https://example.com/file2.zip About + + + true + + + Light Theme + + + + + true + + + Dark Theme + + + + + true + + + Follow System + + + + + Toggle Light/Dark + + + Ctrl+T + + diff --git a/samples/networkdownloader/downloadermainwindow.cpp b/samples/networkdownloader/downloadermainwindow.cpp index f10a427..c5c091d 100644 --- a/samples/networkdownloader/downloadermainwindow.cpp +++ b/samples/networkdownloader/downloadermainwindow.cpp @@ -1,5 +1,6 @@ #include "downloadermainwindow.h" #include "ui_NetworkDownloaderMainWindow.h" +#include "thememanager.h" #include #include #include @@ -78,6 +79,37 @@ QtNetworkRequest::NetworkDownloaderMainWindow::NetworkDownloaderMainWindow(QWidg // Setup connections setupConnections(); + // Theme: create the manager, apply the persisted mode, and wire the + // View-menu actions (exclusive group) + toolbar toggle button. + m_theme = new ThemeManager(this); + m_theme->setMode(m_theme->mode()); + + m_themeGroup = new QActionGroup(this); + m_themeGroup->setExclusive(true); + m_themeGroup->addAction(ui->actionThemeLight); + m_themeGroup->addAction(ui->actionThemeDark); + m_themeGroup->addAction(ui->actionThemeSystem); + syncThemeActionGroup(); + connect(ui->actionThemeLight, &QAction::triggered, this, &NetworkDownloaderMainWindow::onActionThemeLight); + connect(ui->actionThemeDark, &QAction::triggered, this, &NetworkDownloaderMainWindow::onActionThemeDark); + connect(ui->actionThemeSystem, &QAction::triggered, this, &NetworkDownloaderMainWindow::onActionThemeSystem); + connect(ui->actionToggleTheme, &QAction::triggered, this, &NetworkDownloaderMainWindow::onActionToggleTheme); + connect(m_theme, &ThemeManager::modeChanged, this, [this](ThemeManager::Mode){ syncThemeActionGroup(); }); + + // Inject a compact theme-toggle button into the status frame (right side, + // next to labelTime) so users can switch light/dark without opening the + // View menu — parity with the QtRequester toolbar toggle. + if (auto *statusLayout = qobject_cast(ui->statusFrame->layout())) + { + auto *btnTheme = new QPushButton(QStringLiteral("\xe2\x98\xbe")); // ☾ + btnTheme->setObjectName(QStringLiteral("btn_theme")); + btnTheme->setToolTip(QStringLiteral("Toggle light/dark theme")); + btnTheme->setFixedWidth(36); + statusLayout->addStretch(); + statusLayout->addWidget(btnTheme); + connect(btnTheme, &QPushButton::clicked, m_theme, &ThemeManager::toggle); + } + // Load settings loadGeometrySettings(); @@ -421,6 +453,43 @@ void QtNetworkRequest::NetworkDownloaderMainWindow::onActionExit() close(); } +// ── Theme handling ─────────────────────────────────────────────────────────── +void QtNetworkRequest::NetworkDownloaderMainWindow::onActionThemeLight() +{ + if (m_theme) m_theme->setMode(ThemeManager::Mode::Light); +} + +void QtNetworkRequest::NetworkDownloaderMainWindow::onActionThemeDark() +{ + if (m_theme) m_theme->setMode(ThemeManager::Mode::Dark); +} + +void QtNetworkRequest::NetworkDownloaderMainWindow::onActionThemeSystem() +{ + if (m_theme) m_theme->setMode(ThemeManager::Mode::System); +} + +void QtNetworkRequest::NetworkDownloaderMainWindow::onActionToggleTheme() +{ + if (m_theme) m_theme->toggle(); +} + +void QtNetworkRequest::NetworkDownloaderMainWindow::syncThemeActionGroup() +{ + if (!m_theme) + return; + const auto mode = m_theme->mode(); + QAction *checked = nullptr; + if (mode == ThemeManager::Mode::Light) + checked = ui->actionThemeLight; + else if (mode == ThemeManager::Mode::Dark) + checked = ui->actionThemeDark; + else + checked = ui->actionThemeSystem; + if (checked) + checked->setChecked(true); +} + void QtNetworkRequest::NetworkDownloaderMainWindow::updateUI() { int runningCount = m_taskModel->getRunningTaskCount(); diff --git a/samples/networkdownloader/downloadermainwindow.h b/samples/networkdownloader/downloadermainwindow.h index 5c5af1c..1d91af5 100644 --- a/samples/networkdownloader/downloadermainwindow.h +++ b/samples/networkdownloader/downloadermainwindow.h @@ -7,11 +7,14 @@ #include #include #include +#include #include #include #include "downloadtaskmodel.h" #include "downloadmanager.h" +class ThemeManager; + namespace Ui { class DownloaderMainWindow; @@ -49,12 +52,18 @@ private slots: void onActionSettings(); void onActionAbout(); void onActionExit(); + void onActionThemeLight(); + void onActionThemeDark(); + void onActionThemeSystem(); + void onActionToggleTheme(); private: Ui::DownloaderMainWindow *ui; NetworkDownloadTaskModel *m_taskModel; NetworkDownloadManager *m_downloadManager; QSettings m_settings; + ThemeManager *m_theme{nullptr}; + QActionGroup *m_themeGroup{nullptr}; // Notification system QList m_notifications; @@ -69,6 +78,7 @@ private slots: void showAboutDialog(); void saveGeometrySettings(); void loadGeometrySettings(); + void syncThemeActionGroup(); // Non-modal notification system void showNotification(const QString &message, const QString &type = "info", int duration = 3000); diff --git a/samples/networkdownloader/main.cpp b/samples/networkdownloader/main.cpp index d03b633..2bbcaaa 100644 --- a/samples/networkdownloader/main.cpp +++ b/samples/networkdownloader/main.cpp @@ -1,5 +1,6 @@ #include #include "downloadermainwindow.h" +#include "thememanager.h" int main(int argc, char *argv[]) { @@ -15,6 +16,10 @@ int main(int argc, char *argv[]) app.setOrganizationName("QtDownloader"); app.setOrganizationDomain("qtdownloader.com"); + // Apply the persisted theme (Light/Dark/System) before any widget is + // shown so the very first paint is already themed. + ThemeManager::applyStoredMode(); + // Create and show main window QtNetworkRequest::NetworkDownloaderMainWindow mainWindow; mainWindow.show(); diff --git a/samples/networkdownloader/resources/styles/precision-dark.qss b/samples/networkdownloader/resources/styles/precision-dark.qss new file mode 100644 index 0000000..8edee9e --- /dev/null +++ b/samples/networkdownloader/resources/styles/precision-dark.qss @@ -0,0 +1,710 @@ +/* ================================================================ + Precision Workshop — Qt 5.15 Dark Theme + 完全避免黑字:每种控件显式设置 color + ================================================================ */ + +/* --- QMainWindow / QDialog --- */ +QMainWindow, QDialog { + background-color: #12121a; + color: #e4e3ed; + font-family: "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +/* --- QFrame --- */ +QFrame { + background-color: #1a1a26; + border: 1px solid #2a2a3a; + border-radius: 7px; +} + +QFrame#urlFrame, QFrame#tableFrame, QFrame#controlFrame, +QFrame#frame_request_toolbar { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 10px; +} + +QWidget#frame_history, +QWidget#widget_history { + background: transparent; + border: none; +} + +QFrame#statusFrame { + background-color: #1e2048; + border: 1px solid #363648; + border-radius: 5px; +} + +/* ================================================================ + QLabel + ================================================================ */ +QLabel { color: #e4e3ed; background: transparent; } + +QLabel#label_history, +QLabel#label, +QLabel#labelTasks { + color: #e4e3ed; + font-size: 15px; + font-weight: 700; +} + +QLabel#sidebarLogoIcon { + background-color: #7b8cff; + color: #12121a; + font-weight: 700; + font-size: 13px; + border-radius: 5px; +} + +QLabel#sidebarLogoText { + color: #e4e3ed; + font-size: 15px; + font-weight: 600; +} + +QLabel#labelSpeed { + color: #7b8cff; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#labelTime { + color: #fbbf24; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#m_labelResponseInfo { + color: #6e6d7e; + font-size: 11px; + background-color: #222233; + padding: 4px 12px; +} + +QLabel#statusBarLabel { + color: #12121a; + font-weight: 600; + font-size: 11px; +} + +/* ================================================================ + QLineEdit / QTextEdit / QPlainTextEdit + ================================================================ */ +QLineEdit, QPlainTextEdit, QTextEdit { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 8px; + color: #e4e3ed; + font-size: 13px; + selection-background-color: #7b8cff; + selection-color: #12121a; +} + +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus { border-color: #7b8cff; } + +QLineEdit[readOnly="true"], QTextEdit[readOnly="true"] { + background-color: #161621; + color: #a8a7b8; +} + +QLineEdit#lineEdit_url { + font-family: "Consolas", "Courier New", monospace; + font-size: 14px; + padding: 6px 12px; +} + +QLineEdit#lineEdit_search, +QLineEdit#m_leResponseSearch { + border-radius: 16px; + padding: 5px 12px; + background-color: #161621; + font-size: 12px; +} + +QTextEdit#textEdit_response_body, +QTextEdit#textEdit_response_headers, +QTextEdit#textEdit_response_cookies, +QTextEdit#textEdit_body { + background-color: #161621; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + border: none; + color: #e4e3ed; +} + +QPlainTextEdit#plainTextEditUrls { + background-color: #161621; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + color: #e4e3ed; +} + +/* ================================================================ + QComboBox + ================================================================ */ +QComboBox { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 28px 5px 10px; + color: #e4e3ed; + font-size: 13px; + min-width: 80px; +} + +QComboBox:focus { border-color: #7b8cff; } + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border: none; +} + +QComboBox QAbstractItemView { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 5px; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; + padding: 2px; +} + +QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 3px; + color: #e4e3ed; +} + +QComboBox QAbstractItemView::item:hover { background-color: #242438; } +QComboBox QAbstractItemView::item:selected { background-color: #2a2a48; color: #7b8cff; } + +QComboBox#cmb_method { + font-weight: 700; + min-width: 100px; + color: #7b8cff; + font-size: 14px; + padding: 4px 10px; +} + +QComboBox#cmb_environment { font-size: 12px; color: #a8a7b8; min-width: 90px; } +QComboBox#cmb_body_type { font-size: 12px; font-weight: 500; color: #e4e3ed; } + +/* ================================================================ + QPushButton + ================================================================ */ +QPushButton { + background-color: #7b8cff; + color: #12121a; + border: none; + border-radius: 5px; + padding: 6px 14px; + font-size: 13px; + font-weight: 600; +} + +QPushButton:hover { background-color: #8f9dff; } +QPushButton:pressed { background-color: #6a7cee; } +QPushButton:disabled { background-color: #4a4a5a; color: #6e6d7e; } + +QPushButton#btn_send { + background-color: #7b8cff; + color: #12121a; + font-weight: 700; + padding: 6px 18px; +} +QPushButton#btn_send:hover { background-color: #8f9dff; } + +QPushButton#btn_new_request, +QPushButton#btnStart, +QPushButton#btnAddTasks { + background-color: #34d399; + color: #12121a; +} +QPushButton#btn_new_request:hover, +QPushButton#btnStart:hover, +QPushButton#btnAddTasks:hover { background-color: #2bb87a; } + +QPushButton#btn_save { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + font-weight: 600; +} +QPushButton#btn_save:hover { background-color: #242438; color: #e4e3ed; } + +QPushButton#btnCancel { + background-color: #f87171; + color: #12121a; +} +QPushButton#btnCancel:hover { background-color: #e55555; } + +QPushButton#btnDelete { + background-color: #fbbf24; + color: #12121a; +} +QPushButton#btnDelete:hover { background-color: #e0a800; } + +QPushButton#btn_add_param, QPushButton#btn_remove_param, +QPushButton#btn_add_header, QPushButton#btn_remove_header, +QPushButton#btn_add_body, QPushButton#btn_remove_body, +QPushButton#btn_settings, QPushButton#btn_manage_env, +QPushButton#btn_binary_browse { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + font-weight: 500; + padding: 4px 10px; + font-size: 12px; +} + +QPushButton#btn_add_param:hover, QPushButton#btn_remove_param:hover, +QPushButton#btn_add_header:hover, QPushButton#btn_remove_header:hover, +QPushButton#btn_add_body:hover, QPushButton#btn_remove_body:hover, +QPushButton#btn_settings:hover, QPushButton#btn_manage_env:hover, +QPushButton#btn_binary_browse:hover { + background-color: #242438; + color: #e4e3ed; +} + +/* Collection toolbar buttons (injected) */ +QPushButton#btn_coll { + background-color: transparent; + color: #a8a7b8; + border: none; + border-radius: 4px; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll:hover { + background-color: #242438; + color: #7b8cff; +} + +QPushButton#btn_coll_secondary { + background-color: transparent; + color: #6e6d7e; + border: none; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll_secondary:hover { + background-color: #242438; + color: #e4e3ed; +} + +QWidget#collectionToolbar { background: transparent; } + +/* Sidebar tabs (历史 / 收藏夹) */ +QPushButton#tabHistory, QPushButton#tabCollections { + background: transparent; + color: #6e6d7e; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-weight: 600; + font-size: 11px; + padding: 6px 16px; +} +QPushButton#tabHistory:hover, QPushButton#tabCollections:hover { + color: #a8a7b8; +} +QPushButton#tabHistory:checked, QPushButton#tabCollections:checked { + color: #7b8cff; + border-bottom: 2px solid #7b8cff; + background: transparent; +} + +QWidget#sidebarTabs { + background: transparent; + border-bottom: 1px solid #2a2a3a; +} + +/* Response area tab buttons (Body/Headers/Cookies) */ +QPushButton#tabResp { + background: transparent; + color: #6e6d7e; + border: none; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 4px 10px; +} +QPushButton#tabResp:hover { color: #a8a7b8; background-color: #242438; } +QPushButton#tabResp:checked { + color: #7b8cff; + background-color: #1e2048; +} + +/* Response toolbar buttons (injected) */ +QPushButton#btn_resp_nav { + background-color: #161621; + color: #a8a7b8; + border: 1px solid #363648; + border-radius: 4px; + font-weight: 700; + font-size: 14px; + padding: 0; +} +QPushButton#btn_resp_nav:hover { background-color: #1e2048; color: #7b8cff; border-color: #7b8cff; } + +QPushButton#btn_resp_secondary { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 3px 10px; +} +QPushButton#btn_resp_secondary:hover { + background-color: #1e2048; + color: #e4e3ed; + border-color: #7b8cff; +} + +/* Pretty toggle switch */ +QCheckBox#chk_resp_pretty { + color: #a8a7b8; + font-size: 12px; + spacing: 8px; +} +QCheckBox#chk_resp_pretty::indicator { + width: 32px; + height: 18px; + border: none; + border-radius: 9px; + background-color: #363648; +} +QCheckBox#chk_resp_pretty::indicator:hover { + background-color: #4a4a5a; +} +QCheckBox#chk_resp_pretty::indicator:checked { + background-color: #7b8cff; +} + +/* ================================================================ + QTabWidget / QTabBar + ================================================================ */ +QTabWidget::pane { border: none; background-color: #1a1a26; } + +QTabBar::tab { + background: transparent; + color: #6e6d7e; + padding: 6px 10px; + font-size: 13px; + font-weight: 500; + border: none; + border-bottom: 2px solid transparent; + min-width: 60px; +} + +QTabBar::tab:hover { color: #a8a7b8; } + +QTabBar::tab:selected { + color: #7b8cff; + border-bottom: 2px solid #7b8cff; +} + +/* ================================================================ + QTableWidget + ================================================================ */ +QTableWidget { + background-color: #1a1a26; + border: none; + gridline-color: #2a2a3a; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; +} + +QTableWidget::item { padding: 3px 8px; color: #e4e3ed; } +QTableWidget::item:hover { background-color: #242438; } +QTableWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QTableView + ================================================================ */ +QTableView { + background-color: #1a1a26; + border: none; + gridline-color: #2a2a3a; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; + alternate-background-color: #222233; +} + +QTableView::item { padding: 6px 12px; color: #e4e3ed; } +QTableView::item:hover { background-color: #242438; } +QTableView::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QHeaderView + ================================================================ */ +QHeaderView::section { + background-color: #222233; + color: #6e6d7e; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + border: none; + border-bottom: 1px solid #363648; +} + +QHeaderView::section:hover { color: #e4e3ed; } + +QTableCornerButton::section { + background-color: #222233; + border: none; + border-bottom: 1px solid #363648; + border-right: 1px solid #363648; +} + +/* ================================================================ + QListWidget + ================================================================ */ +QListWidget { + background-color: #1a1a26; + border: none; + color: #e4e3ed; + outline: none; +} + +QListWidget::item { + padding: 8px 12px; + border-radius: 3px; + margin: 1px 4px; + color: #e4e3ed; +} + +QListWidget::item:hover { background-color: #242438; } +QListWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QTreeWidget + ================================================================ */ +QTreeWidget { + background-color: #1a1a26; + border: none; + color: #e4e3ed; + outline: none; +} + +QTreeWidget::item { + padding: 5px 8px; + border-radius: 3px; + margin: 1px 4px; + color: #e4e3ed; +} + +QTreeWidget::item:hover { background-color: #242438; } +QTreeWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +QTreeWidget::branch:has-children:!has-siblings:closed, +QTreeWidget::branch:closed:has-children:has-siblings { border-image: none; } + +/* ================================================================ + QProgressBar + ================================================================ */ +QProgressBar { + background-color: #161621; + border: none; + border-radius: 3px; + height: 6px; + text-align: center; + font-size: 1px; + color: transparent; +} + +QProgressBar::chunk { background-color: #7b8cff; border-radius: 3px; } + +/* ================================================================ + QSplitter + ================================================================ */ +QSplitter::handle { background-color: #2a2a3a; } +QSplitter::handle:hover { background-color: #7b8cff; } +QSplitter::handle:horizontal { width: 4px; } +QSplitter::handle:vertical { height: 4px; } + +/* ================================================================ + QMenuBar / QMenu + ================================================================ */ +QMenuBar { + background-color: #222233; + border-bottom: 1px solid #2a2a3a; + padding: 2px 8px; + color: #a8a7b8; + font-size: 13px; +} + +QMenuBar::item { padding: 4px 10px; border-radius: 3px; color: #a8a7b8; } +QMenuBar::item:selected { background-color: #242438; color: #e4e3ed; } + +QMenu { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 7px; + padding: 4px; + color: #e4e3ed; +} + +QMenu::item { padding: 6px 28px 6px 12px; border-radius: 3px; color: #e4e3ed; } +QMenu::item:selected { background-color: #2a2a48; color: #7b8cff; } +QMenu::separator { height: 1px; background-color: #2a2a3a; margin: 4px 8px; } + +/* ================================================================ + QStatusBar + ================================================================ */ +QStatusBar { + background-color: #7b8cff; + color: #12121a; + font-size: 11px; + font-weight: 700; + padding: 3px 14px; + min-height: 22px; + border-top: 2px solid #12121a; +} + +QStatusBar::item { border: none; } +QSizeGrip { background: transparent; width: 12px; height: 12px; } + +/* ================================================================ + QScrollBar + ================================================================ */ +QScrollBar:vertical { background: transparent; width: 8px; margin: 0; } +QScrollBar::handle:vertical { + background-color: #363648; + border-radius: 4px; + min-height: 30px; +} +QScrollBar::handle:vertical:hover { background-color: #4a4a5a; } +QScrollBar::add-line:vertical { height: 0; } +QScrollBar::sub-line:vertical { height: 0; } + +QScrollBar:horizontal { background: transparent; height: 8px; } +QScrollBar::handle:horizontal { + background-color: #363648; + border-radius: 4px; + min-width: 30px; +} +QScrollBar::handle:horizontal:hover { background-color: #4a4a5a; } +QScrollBar::add-line:horizontal { width: 0; } +QScrollBar::sub-line:horizontal { width: 0; } + +/* ================================================================ + QGroupBox + ================================================================ */ +QGroupBox { + background-color: #1a1a26; + border: 1px solid #2a2a3a; + border-radius: 7px; + margin-top: 14px; + padding-top: 16px; + color: #e4e3ed; + font-weight: 600; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px 12px; + background-color: #222233; + border-radius: 4px; + color: #a8a7b8; + font-size: 12px; + font-weight: 600; +} + +/* ================================================================ + QCheckBox + ================================================================ */ +QCheckBox { + color: #e4e3ed; + spacing: 8px; + font-size: 13px; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #363648; + border-radius: 3px; + background: transparent; +} +QCheckBox::indicator:hover { border-color: #7b8cff; } +QCheckBox::indicator:checked { background-color: #7b8cff; border-color: #7b8cff; } + +/* ================================================================ + QSpinBox + ================================================================ */ +QSpinBox { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 10px; + color: #e4e3ed; + font-size: 13px; +} + +QSpinBox:focus { border-color: #7b8cff; } + +QSpinBox::up-button, QSpinBox::down-button { + width: 20px; + border: none; + background-color: #222233; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { background-color: #242438; } + +/* ================================================================ + QToolTip + ================================================================ */ +QToolTip { + background-color: #e8e6e1; + color: #12121a; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; +} + +/* QDialogButtonBox / QMessageBox */ +QDialogButtonBox QPushButton { + padding: 6px 18px; + font-size: 13px; + font-weight: 600; + border-radius: 5px; + min-width: 80px; +} + +QPushButton[text="OK"], +QPushButton[text="确定"], +QPushButton[text="Save"] { + background-color: #7b8cff; + color: #12121a; + border: none; +} +QPushButton[text="OK"]:hover, +QPushButton[text="确定"]:hover, +QPushButton[text="Save"]:hover { background-color: #8f9dff; } + +QPushButton[text="Cancel"], +QPushButton[text="取消"] { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; +} +QPushButton[text="Cancel"]:hover, +QPushButton[text="取消"]:hover { background-color: #242438; color: #e4e3ed; } + +QMessageBox { background-color: #12121a; } +QMessageBox QLabel { color: #e4e3ed; font-size: 13px; } diff --git a/samples/networkdownloader/resources/styles/precision-light.qss b/samples/networkdownloader/resources/styles/precision-light.qss new file mode 100644 index 0000000..6d0eeab --- /dev/null +++ b/samples/networkdownloader/resources/styles/precision-light.qss @@ -0,0 +1,780 @@ +/* ================================================================ + Precision Workshop — Qt 5.15 Light Theme + 完全匹配 HTML 原型 qt-requester-prototype.html + ================================================================ */ + +/* --- QMainWindow / QDialog --- */ +QMainWindow, QDialog { + background-color: #f8f7f4; + color: #1a1a24; + font-family: "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +/* --- QFrame --- */ +QFrame { + background-color: #ffffff; + border: 1px solid #e8e6e1; + border-radius: 7px; +} + +QFrame#urlFrame, QFrame#tableFrame, QFrame#controlFrame, +QFrame#frame_request_toolbar { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 10px; +} + +QWidget#frame_history, +QWidget#widget_history { + background: transparent; + border: none; +} + +QFrame#statusFrame { + background-color: #eef0ff; + border: 1px solid #dbd9d2; + border-radius: 5px; +} + +/* ================================================================ + QLabel — 显式 color 防黑字 + ================================================================ */ +QLabel { + color: #1a1a24; + background: transparent; +} + +QLabel#label_history, +QLabel#label, +QLabel#labelTasks { + color: #1a1a24; + font-size: 15px; + font-weight: 700; +} + +QLabel#sidebarLogoIcon { + background-color: #4361ee; + color: #ffffff; + font-weight: 700; + font-size: 13px; + border-radius: 5px; +} + +QLabel#sidebarLogoText { + color: #1a1a24; + font-size: 15px; + font-weight: 600; +} + +QLabel#labelSpeed { + color: #4361ee; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#labelTime { + color: #d97706; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#m_labelResponseInfo { + color: #8f8e99; + font-size: 11px; + background-color: #fafaf8; + padding: 4px 12px; +} + +QLabel#statusBarLabel { + color: #ffffff; + font-weight: 600; + font-size: 11px; +} + +/* ================================================================ + QLineEdit / QTextEdit / QPlainTextEdit — 显式 color + ================================================================ */ +QLineEdit, QPlainTextEdit, QTextEdit { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 8px; + color: #1a1a24; + font-size: 13px; + selection-background-color: #4361ee; + selection-color: #ffffff; +} + +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus { + border-color: #4361ee; +} + +QLineEdit[readOnly="true"], QTextEdit[readOnly="true"] { + background-color: #f1f0ec; + color: #5e5d6b; +} + +QLineEdit#lineEdit_url { + font-family: "Consolas", "Courier New", monospace; + font-size: 14px; + padding: 6px 12px; +} + +QLineEdit#lineEdit_search, +QLineEdit#m_leResponseSearch { + border-radius: 16px; + padding: 5px 12px; + background-color: #f1f0ec; + font-size: 12px; +} + +QTextEdit#textEdit_response_body, +QTextEdit#textEdit_response_headers, +QTextEdit#textEdit_response_cookies, +QTextEdit#textEdit_body { + background-color: #f1f0ec; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + border: none; + color: #1a1a24; +} + +QPlainTextEdit#plainTextEditUrls { + background-color: #f1f0ec; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + color: #1a1a24; +} + +/* ================================================================ + QComboBox — 显式 color + ================================================================ */ +QComboBox { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 28px 5px 10px; + color: #1a1a24; + font-size: 13px; + min-width: 80px; +} + +QComboBox:focus { border-color: #4361ee; } + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border: none; +} + +QComboBox QAbstractItemView { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + selection-background-color: #e8e7f5; + color: #1a1a24; + selection-color: #1a1a24; + outline: none; + padding: 2px; +} + +QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 3px; + color: #1a1a24; +} + +QComboBox QAbstractItemView::item:hover { background-color: #eeedf5; } +QComboBox QAbstractItemView::item:selected { background-color: #e8e7f5; color: #4361ee; } + +QComboBox#cmb_method { + font-weight: 700; + min-width: 100px; + color: #4361ee; + font-size: 14px; + padding: 4px 10px; +} + +QComboBox#cmb_environment { + font-size: 12px; + color: #5e5d6b; + min-width: 90px; +} + +QComboBox#cmb_body_type { + font-size: 12px; + font-weight: 500; + color: #1a1a24; +} + +/* ================================================================ + QPushButton — 显式 color + 缩小 padding + ================================================================ */ +QPushButton { + background-color: #4361ee; + color: #ffffff; + border: none; + border-radius: 5px; + padding: 6px 14px; + font-size: 13px; + font-weight: 600; +} + +QPushButton:hover { background-color: #3451d1; } +QPushButton:pressed { background-color: #2a42b0; } +QPushButton:disabled { + background-color: #dbd9d2; + color: #8f8e99; +} + +QPushButton#btn_send { + background-color: #4361ee; + color: #ffffff; + font-weight: 700; + padding: 6px 18px; +} +QPushButton#btn_send:hover { background-color: #3451d1; } + +QPushButton#btn_new_request, +QPushButton#btnStart, +QPushButton#btnAddTasks { + background-color: #2d8a56; + color: #ffffff; +} +QPushButton#btn_new_request:hover, +QPushButton#btnStart:hover, +QPushButton#btnAddTasks:hover { background-color: #237a48; } + +QPushButton#btn_save { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + font-weight: 600; +} +QPushButton#btn_save:hover { background-color: #eeedf5; color: #1a1a24; } + +QPushButton#btnCancel { + background-color: #dc2626; + color: #ffffff; +} +QPushButton#btnCancel:hover { background-color: #c42121; } + +QPushButton#btnDelete { + background-color: #d97706; + color: #ffffff; +} +QPushButton#btnDelete:hover { background-color: #c06805; } + +/* Ghost / toolbar buttons */ +QPushButton#btn_add_param, QPushButton#btn_remove_param, +QPushButton#btn_add_header, QPushButton#btn_remove_header, +QPushButton#btn_add_body, QPushButton#btn_remove_body, +QPushButton#btn_settings, QPushButton#btn_manage_env, +QPushButton#btn_binary_browse { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + font-weight: 500; + padding: 4px 10px; + font-size: 12px; +} + +QPushButton#btn_add_param:hover, QPushButton#btn_remove_param:hover, +QPushButton#btn_add_header:hover, QPushButton#btn_remove_header:hover, +QPushButton#btn_add_body:hover, QPushButton#btn_remove_body:hover, +QPushButton#btn_settings:hover, QPushButton#btn_manage_env:hover, +QPushButton#btn_binary_browse:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +/* Collection toolbar buttons (injected) */ +QPushButton#btn_coll { + background-color: transparent; + color: #5e5d6b; + border: none; + border-radius: 4px; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll:hover { + background-color: #eeedf5; + color: #4361ee; +} + +QPushButton#btn_coll_secondary { + background-color: transparent; + color: #8f8e99; + border: none; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll_secondary:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +QWidget#collectionToolbar { background: transparent; } + +/* Sidebar tabs (历史 / 收藏夹) */ +QPushButton#tabHistory, QPushButton#tabCollections { + background: transparent; + color: #8f8e99; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-weight: 600; + font-size: 11px; + padding: 6px 16px; + text-transform: uppercase; +} +QPushButton#tabHistory:hover, QPushButton#tabCollections:hover { + color: #5e5d6b; +} +QPushButton#tabHistory:checked, QPushButton#tabCollections:checked { + color: #4361ee; + border-bottom: 2px solid #4361ee; + background: transparent; +} + +QWidget#sidebarTabs { + background: transparent; + border-bottom: 1px solid #e8e6e1; +} + +/* Response area tab buttons (Body/Headers/Cookies) */ +QPushButton#tabResp { + background: transparent; + color: #8f8e99; + border: none; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 4px 10px; +} +QPushButton#tabResp:hover { color: #5e5d6b; background-color: #eeedf5; } +QPushButton#tabResp:checked { + color: #4361ee; + background-color: #eef0ff; +} + +/* Response toolbar buttons (injected) */ +QPushButton#btn_resp_nav { + background-color: #f1f0ec; + color: #5e5d6b; + border: 1px solid #dbd9d2; + border-radius: 4px; + font-weight: 700; + font-size: 14px; + padding: 0; +} +QPushButton#btn_resp_nav:hover { background-color: #e8e7f5; color: #4361ee; border-color: #4361ee; } + +QPushButton#btn_resp_secondary { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 3px 10px; +} +QPushButton#btn_resp_secondary:hover { + background-color: #eeedf5; + color: #1a1a24; + border-color: #4361ee; +} + +/* Pretty toggle switch */ +QCheckBox#chk_resp_pretty { + color: #5e5d6b; + font-size: 12px; + spacing: 8px; +} +QCheckBox#chk_resp_pretty::indicator { + width: 32px; + height: 18px; + border: none; + border-radius: 9px; + background-color: #dbd9d2; +} +QCheckBox#chk_resp_pretty::indicator:hover { + background-color: #bfbec5; +} +QCheckBox#chk_resp_pretty::indicator:checked { + background-color: #4361ee; +} + +/* ================================================================ + QTabWidget / QTabBar — 显式 color + ================================================================ */ +QTabWidget::pane { + border: none; + background-color: #ffffff; +} + +QTabBar::tab { + background: transparent; + color: #8f8e99; + padding: 6px 10px; + font-size: 13px; + font-weight: 500; + border: none; + border-bottom: 2px solid transparent; + min-width: 60px; +} + +QTabBar::tab:hover { color: #5e5d6b; } + +QTabBar::tab:selected { + color: #4361ee; + border-bottom: 2px solid #4361ee; +} + +/* ================================================================ + QTableWidget — 显式 color + ================================================================ */ +QTableWidget { + background-color: #ffffff; + border: none; + gridline-color: #e8e6e1; + color: #1a1a24; + selection-background-color: #e8e7f5; + selection-color: #1a1a24; + outline: none; +} + +QTableWidget::item { + padding: 3px 8px; + color: #1a1a24; +} + +QTableWidget::item:hover { background-color: #eeedf5; } +QTableWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QTableView (Downloader) — 显式 color + ================================================================ */ +QTableView { + background-color: #ffffff; + border: none; + gridline-color: #e8e6e1; + color: #1a1a24; + selection-background-color: #e8e7f5; + selection-color: #1a1a24; + outline: none; + alternate-background-color: #fafaf8; +} + +QTableView::item { + padding: 6px 12px; + color: #1a1a24; +} + +QTableView::item:hover { background-color: #eeedf5; } +QTableView::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QHeaderView — 显式 color + ================================================================ */ +QHeaderView::section { + background-color: #fafaf8; + color: #8f8e99; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + border: none; + border-bottom: 1px solid #dbd9d2; +} + +QHeaderView::section:hover { color: #1a1a24; } + +QTableCornerButton::section { + background-color: #fafaf8; + border: none; + border-bottom: 1px solid #dbd9d2; + border-right: 1px solid #dbd9d2; +} + +/* ================================================================ + QListWidget — 显式 color + ================================================================ */ +QListWidget { + background-color: #ffffff; + border: none; + color: #1a1a24; + outline: none; +} + +QListWidget::item { + padding: 8px 12px; + border-radius: 3px; + margin: 1px 4px; + color: #1a1a24; +} + +QListWidget::item:hover { background-color: #eeedf5; } +QListWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QTreeWidget — 显式 color + ================================================================ */ +QTreeWidget { + background-color: #ffffff; + border: none; + color: #1a1a24; + outline: none; +} + +QTreeWidget::item { + padding: 5px 8px; + border-radius: 3px; + margin: 1px 4px; + color: #1a1a24; +} + +QTreeWidget::item:hover { background-color: #eeedf5; } +QTreeWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +QTreeWidget::branch:has-children:!has-siblings:closed, +QTreeWidget::branch:closed:has-children:has-siblings { + border-image: none; +} + +/* ================================================================ + QProgressBar + ================================================================ */ +QProgressBar { + background-color: #f1f0ec; + border: none; + border-radius: 3px; + height: 6px; + text-align: center; + font-size: 1px; + color: transparent; +} + +QProgressBar::chunk { + background-color: #4361ee; + border-radius: 3px; +} + +/* ================================================================ + QSplitter + ================================================================ */ +QSplitter::handle { background-color: #e8e6e1; } +QSplitter::handle:hover { background-color: #4361ee; } +QSplitter::handle:horizontal { width: 4px; } +QSplitter::handle:vertical { height: 4px; } + +/* ================================================================ + QMenuBar / QMenu — 显式 color + ================================================================ */ +QMenuBar { + background-color: #fafaf8; + border-bottom: 1px solid #e8e6e1; + padding: 2px 8px; + color: #5e5d6b; + font-size: 13px; +} + +QMenuBar::item { + padding: 4px 10px; + border-radius: 3px; + color: #5e5d6b; +} + +QMenuBar::item:selected { + background-color: #eeedf5; + color: #1a1a24; +} + +QMenu { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 7px; + padding: 4px; + color: #1a1a24; +} + +QMenu::item { + padding: 6px 28px 6px 12px; + border-radius: 3px; + color: #1a1a24; +} + +QMenu::item:selected { + background-color: #e8e7f5; + color: #4361ee; +} + +QMenu::separator { + height: 1px; + background-color: #e8e6e1; + margin: 4px 8px; +} + +/* ================================================================ + QStatusBar — branded indigo footer + ================================================================ */ +QStatusBar { + background-color: #4361ee; + color: #ffffff; + font-size: 11px; + font-weight: 700; + padding: 3px 14px; + min-height: 22px; + border-top: 2px solid #2a42b0; +} + +QStatusBar::item { border: none; } +QSizeGrip { background: transparent; width: 12px; height: 12px; } + +/* ================================================================ + QScrollBar + ================================================================ */ +QScrollBar:vertical { background: transparent; width: 8px; margin: 0; } +QScrollBar::handle:vertical { + background-color: #dbd9d2; + border-radius: 4px; + min-height: 30px; +} +QScrollBar::handle:vertical:hover { background-color: #bfbec5; } +QScrollBar::add-line:vertical { height: 0; } +QScrollBar::sub-line:vertical { height: 0; } + +QScrollBar:horizontal { background: transparent; height: 8px; } +QScrollBar::handle:horizontal { + background-color: #dbd9d2; + border-radius: 4px; + min-width: 30px; +} +QScrollBar::handle:horizontal:hover { background-color: #bfbec5; } +QScrollBar::add-line:horizontal { width: 0; } +QScrollBar::sub-line:horizontal { width: 0; } + +/* ================================================================ + QGroupBox — 显式 color + ================================================================ */ +QGroupBox { + background-color: #ffffff; + border: 1px solid #e8e6e1; + border-radius: 7px; + margin-top: 14px; + padding-top: 16px; + color: #1a1a24; + font-weight: 600; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px 12px; + background-color: #fafaf8; + border-radius: 4px; + color: #5e5d6b; + font-size: 12px; + font-weight: 600; +} + +/* ================================================================ + QCheckBox — 显式 color + ================================================================ */ +QCheckBox { + color: #1a1a24; + spacing: 8px; + font-size: 13px; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #dbd9d2; + border-radius: 3px; + background: transparent; +} +QCheckBox::indicator:hover { border-color: #4361ee; } +QCheckBox::indicator:checked { background-color: #4361ee; border-color: #4361ee; } + +/* ================================================================ + QSpinBox — 显式 color + ================================================================ */ +QSpinBox { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 10px; + color: #1a1a24; + font-size: 13px; +} + +QSpinBox:focus { border-color: #4361ee; } + +QSpinBox::up-button, QSpinBox::down-button { + width: 20px; + border: none; + background-color: #fafaf8; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #eeedf5; +} + +/* ================================================================ + QToolTip — 显式 color + ================================================================ */ +QToolTip { + background-color: #1a1a24; + color: #f8f7f4; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; +} + +/* ================================================================ + QDialogButtonBox — dialog buttons (OK=accent, Cancel=ghost) + ================================================================ */ +QDialogButtonBox QPushButton { + padding: 6px 18px; + font-size: 13px; + font-weight: 600; + border-radius: 5px; + min-width: 80px; +} + +/* OK / Accept button — accent color */ +QPushButton[text="OK"], +QPushButton[text="确定"], +QPushButton[text="Save"] { + background-color: #4361ee; + color: #ffffff; + border: none; +} +QPushButton[text="OK"]:hover, +QPushButton[text="确定"]:hover, +QPushButton[text="Save"]:hover { + background-color: #3451d1; +} + +/* Cancel / Close — ghost style */ +QPushButton[text="Cancel"], +QPushButton[text="取消"] { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; +} +QPushButton[text="Cancel"]:hover, +QPushButton[text="取消"]:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +/* ================================================================ + QMessageBox — 显式 color + ================================================================ */ +QMessageBox { background-color: #f8f7f4; } +QMessageBox QLabel { color: #1a1a24; font-size: 13px; } diff --git a/samples/networkdownloader/thememanager.h b/samples/networkdownloader/thememanager.h new file mode 100644 index 0000000..912523d --- /dev/null +++ b/samples/networkdownloader/thememanager.h @@ -0,0 +1,153 @@ +#ifndef THEMEMANAGER_H +#define THEMEMANAGER_H + +// ================================================================ +// ThemeManager — runtime Light / Dark / System theme controller. +// +// Loads the "Precision Workshop" QSS themes (embedded as Qt resources +// at ":/styles/precision-light.qss" and ":/styles/precision-dark.qss") +// onto qApp so they cascade over the whole application. The persisted +// choice lives in QSettings("QtNetworkRequest","Theme") under the key +// "themeMode", so it survives restarts and is shared by the sample +// apps and the GUI test harnesses. +// +// System mode resolves the OS preference from the current GUI palette: +// a dark window background ⇒ dark. This is Qt 5.15-safe and needs no +// platform-specific API. +// ================================================================ + +#include +#include +#include +#include +#include +#include +#include + +class ThemeManager : public QObject +{ + Q_OBJECT + +public: + enum class Mode + { + Light, + Dark, + System + }; + Q_ENUM(Mode) + + explicit ThemeManager(QObject *parent = nullptr) + : QObject(parent) + , m_mode(loadMode()) + { + } + + // Apply (and persist) the requested mode. Resolves System to a concrete + // theme, sets qApp's stylesheet, and emits modeChanged(). + void setMode(Mode mode) + { + if (mode == m_mode && !qApp->styleSheet().isEmpty()) + return; + m_mode = mode; + saveMode(mode); + apply(); + emit modeChanged(m_mode); + } + + Mode mode() const { return m_mode; } + + // Resolves System → concrete light/dark flag. + bool isDark() const + { + if (m_mode == Mode::Light) + return false; + if (m_mode == Mode::Dark) + return true; + return systemIsDark(); + } + + // Toolbar-toggle helper: flips between Light and Dark (resolving System + // to its concrete value first so the toggle always changes appearance). + void toggle() + { + setMode(isDark() ? Mode::Light : Mode::Dark); + } + + // Convenience: apply whatever is stored in QSettings to qApp, callable + // from main() before any widget is shown so the first paint is themed. + static void applyStoredMode() + { + const Mode m = loadMode(); + qApp->setStyleSheet(loadStylesheet(m)); + } + + // Theme accent color for inline-styled text (e.g. response body) so it + // stays legible without a QSS rule. Light → indigo, Dark → periwinkle, + // matching the design tokens in the QSS files. + QColor accent() const + { + return isDark() ? QColor(0x7b, 0x8c, 0xff) : QColor(0x43, 0x61, 0xee); + } + +signals: + void modeChanged(Mode mode); + +private: + Mode m_mode; + + static bool systemIsDark() + { + if (!qApp) + return false; + // A dark window-background palette ⇒ dark mode. lightness() is in + // [0,255]; 128 is the conventional midpoint threshold. + const int lightness = qApp->palette().color(QPalette::Window).lightness(); + return lightness < 128; + } + + static QString themeResourcePath(Mode resolvedMode) + { + // resolvedMode must already be concrete (Light/Dark), not System. + if (resolvedMode == Mode::Dark) + return QStringLiteral(":/styles/precision-dark.qss"); + return QStringLiteral(":/styles/precision-light.qss"); + } + + static QString loadStylesheet(Mode mode) + { + const Mode resolved = (mode == Mode::System) + ? (systemIsDark() ? Mode::Dark : Mode::Light) + : mode; + QFile f(themeResourcePath(resolved)); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) + return QString(); + return QString::fromUtf8(f.readAll()); + } + + void apply() + { + if (qApp) + qApp->setStyleSheet(loadStylesheet(m_mode)); + } + + static Mode loadMode() + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + const int v = s.value(QStringLiteral("themeMode"), + static_cast(Mode::System)).toInt(); + if (v == static_cast(Mode::Light)) + return Mode::Light; + if (v == static_cast(Mode::Dark)) + return Mode::Dark; + return Mode::System; + } + + static void saveMode(Mode mode) + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + s.setValue(QStringLiteral("themeMode"), static_cast(mode)); + } +}; + +#endif // THEMEMANAGER_H diff --git a/samples/networkrequesttool/NetworkRequestTool.qrc b/samples/networkrequesttool/NetworkRequestTool.qrc index a52a7f8..b356c98 100644 --- a/samples/networkrequesttool/NetworkRequestTool.qrc +++ b/samples/networkrequesttool/NetworkRequestTool.qrc @@ -2,4 +2,8 @@ resources/networktool.ico + + resources/styles/precision-light.qss + resources/styles/precision-dark.qss + diff --git a/samples/networkrequesttool/NetworkRequestTool.ui b/samples/networkrequesttool/NetworkRequestTool.ui index 3881f63..93676da 100644 --- a/samples/networkrequesttool/NetworkRequestTool.ui +++ b/samples/networkrequesttool/NetworkRequestTool.ui @@ -13,1004 +13,6 @@ Request Tool - - /* VSCode-Inspired Professional Dark Theme - Qt5 Compatible */ -/* ======================================== - DESIGN SYSTEM CONSTANTS - ======================================== */ - -/* Color Definitions (Qt5 compatible) */ -/* Background Colors */ -/* BG Primary: #1e1e1e, BG Secondary: #252526, BG Tertiary: #2d2d30 */ -/* Border Colors: Light #3e3e42, Medium #4e4e52 */ -/* Text Colors: Primary #cccccc, Secondary #969696, Disabled #6a6a6a */ -/* Accent Colors: Blue #0078d4, Green #107c10, Orange #ff8c00 */ -/* State Colors: Hover #106ebe, Pressed #005a9e */ - -/* ======================================== - GLOBAL STYLES - ======================================== */ - -QMainWindow { - background-color: #1e1e1e; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 13px; - font-weight: 400; -} - -QWidget#centralWidget { - background-color: #1e1e1e; - color: #cccccc; -} - -/* ======================================== - CONTAINER STYLES - ======================================== */ - -QFrame { - background-color: #252526; - border: 1px solid #3e3e42; - border-radius: 6px; - padding: 0px; -} - -QFrame[frameShape="0"] { - border: none; - background-color: transparent; -} - -/* ======================================== - TYPOGRAPHY SYSTEM - ======================================== */ - -QLabel { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-weight: 400; - background-color: transparent; -} - -QLabel.heading { - color: #ffffff; - font-size: 16px; - font-weight: 600; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - letter-spacing: -0.2px; -} - -QLabel.subheading { - color: #cccccc; - font-size: 14px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -QLabel.small { - font-size: 11px; - color: #969696; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* ======================================== - INPUT CONTROLS - ======================================== */ - -QLineEdit, QComboBox, QTextEdit { - border: 1px solid #3e3e42; - border-radius: 4px; - padding: 8px 12px; - background-color: #1e1e1e; - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; -} - -QLineEdit:focus, QComboBox:focus, QTextEdit:focus { - border: 2px solid #0078d4; - background-color: #1e1e1e; - outline: none; - padding: 7px 11px; /* Compensate for 2px border */ -} - -QLineEdit:disabled, QComboBox:disabled, QTextEdit:disabled { - background-color: #2d2d30; - color: #6a6a6a; - border-color: #3e3e42; -} - -QLineEdit::placeholder { - color: #6a6a6a; - font-style: italic; -} - -QLineEdit:hover, QComboBox:hover { - border-color: #4e4e52; -} - -QLineEdit:focus:hover, QComboBox:focus:hover { - border-color: #0078d4; -} - -/* ======================================== - COMBOBOX STYLING - ======================================== */ - -QComboBox { - min-width: 100px; - padding-right: 24px; /* Space for dropdown arrow */ - text-align: left; -} - -QComboBox QAbstractItemView { - background-color: #252526; - color: #cccccc; - border: 1px solid #4e4e52; - selection-background-color: #0078d4; - selection-color: #ffffff; - outline: none; - border-radius: 4px; - padding: 4px 0; - font-size: 13px; -} - -QComboBox QAbstractItemView::item { - padding: 8px 12px; - min-height: 20px; - border: none; - margin: 1px 8px; - border-radius: 3px; -} - -/* Note: Qt5 may have limited support for nested pseudo-classes */ - -QComboBox::drop-down { - subcontrol-origin: padding; - subcontrol-position: top right; - width: 24px; - border-left: 1px solid #3e3e42; - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; - background-color: #2d2d30; -} - -QComboBox::drop-down:hover { - background-color: #3e3e42; - border-left-color: #4e4e52; -} - -QComboBox::drop-down:pressed { - background-color: #0078d4; - border-left-color: #0078d4; -} - -QComboBox::down-arrow { - image: none; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid #cccccc; - margin: 2px; -} - -QComboBox::down-arrow:hover { - border-top-color: #ffffff; -} - -QComboBox::down-arrow:pressed { - border-top-color: #ffffff; -} - -QComboBox:disabled { - background-color: #2d2d30; - color: #6a6a6a; -} - -QComboBox:disabled::drop-down { - background-color: #2d2d30; - border-left-color: #3e3e42; -} - -QComboBox:disabled::down-arrow { - border-top-color: #6a6a6a; -} - -/* ======================================== - TAB WIDGET STYLING - ======================================== */ - -QTabWidget::pane { - border: 1px solid #3e3e42; - border-radius: 6px; - background-color: #252526; - padding: 4px; - top: -1px; -} - -QTabBar::tab { - background-color: transparent; - color: #969696; - border: none; - border-bottom: 3px solid transparent; - padding: 12px 20px 14px 20px; - margin-right: 2px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 13px; - min-width: 80px; - min-height: 20px; - letter-spacing: 0.1px; - line-height: 16px; -} - -QTabBar::tab:selected { - color: #ffffff; - border-bottom: 3px solid #0078d4; - background-color: #252526; -} - -/* Note: Qt5 doesn't support :!selected - simplified hover */ - -QTabBar::tab:first { - margin-left: 4px; -} - -/* ======================================== - TABLE WIDGET STYLING - ======================================== */ - -QTableWidget { - border: 1px solid #3e3e42; - border-radius: 6px; - background-color: #252526; - alternate-background-color: #2d2d30; - gridline-color: #3e3e42; - selection-background-color: #0078d4; - selection-color: #ffffff; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - outline: none; -} - -QTableWidget::item { - padding: 8px 12px; - border-bottom: 1px solid #3e3e42; - color: #cccccc; - min-height: 28px; - background-color: #252526; -} - -QTableWidget::item:selected { - background-color: #0078d4; - color: #ffffff; -} - -/* Note: Qt5 doesn't support complex pseudo-classes like :!selected and :alternate:hover:!selected */ - -/* ======================================== - TABLE EDITOR STYLING - ======================================== */ - -QTableWidget QLineEdit { - background-color: #1e1e1e; - color: #cccccc; - border: 2px solid #0078d4; - padding: 4px 8px; - border-radius: 3px; - font-size: 12px; -} - -QTableWidget QLineEdit:focus { - background-color: #1e1e1e; - border: 2px solid #0078d4; - outline: none; -} - -QTableWidget QComboBox { - background-color: #1e1e1e; - color: #cccccc; - border: 2px solid #0078d4; - padding: 4px 8px; - border-radius: 3px; - min-height: 20px; - font-size: 12px; -} - -QTableWidget QComboBox:focus { - background-color: #1e1e1e; - border: 2px solid #0078d4; - outline: none; -} - -/* ======================================== - TABLE HEADER STYLING - ======================================== */ - -QHeaderView::section { - background-color: #2d2d30; - color: #cccccc; - padding: 10px 12px; - border: none; - border-right: 1px solid #3e3e42; - border-bottom: 2px solid #0078d4; - font-weight: 600; - font-size: 12px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - letter-spacing: 0.2px; - text-transform: uppercase; -} - -QHeaderView::section:first { - border-top-left-radius: 6px; -} - -QHeaderView::section:last { - border-top-right-radius: 6px; - border-right: none; -} - -QHeaderView::section:hover { - background-color: #333337; - color: #ffffff; -} - -QHeaderView::section:pressed { - background-color: #0078d4; - color: #ffffff; -} - -/* ======================================== - LIST WIDGET STYLING - ======================================== */ - -QListWidget { - border: 1px solid #3e3e42; - border-radius: 6px; - background-color: #252526; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - outline: none; - padding: 4px; -} - -QListWidget::item { - padding: 10px 12px; - border: none; - margin: 2px; - color: #cccccc; - border-radius: 4px; - background-color: transparent; - min-height: 24px; -} - -QListWidget::item:selected { - background-color: #0078d4; - color: #ffffff; -} - -/* Note: Qt5 doesn't support :hover:!selected - simplified for compatibility */ - -QListWidget::item:focus { - border: 1px solid #0078d4; - border-radius: 3px; -} - -/* ======================================== - BUTTON STYLING SYSTEM - ======================================== */ - -QPushButton { - background-color: #0078d4; - color: #ffffff; - border: none; - border-radius: 6px; - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - min-width: 80px; - min-height: 32px; - letter-spacing: 0.1px; - text-align: center; -} - -QPushButton:hover { - background-color: #106ebe; -} - -QPushButton:pressed { - background-color: #005a9e; -} - -QPushButton:disabled { - background-color: #3e3e42; - color: #6a6a6a; -} - -QPushButton:focus { - border: 2px solid #0078d4; - padding: 6px 14px; -} - -/* Primary Action Buttons */ -QPushButton#btn_new_request { - background-color: #107c10; - font-weight: 600; -} - -QPushButton#btn_new_request:hover { - background-color: #128a0f; -} - -QPushButton#btn_new_request:pressed { - background-color: #0d5e0d; -} - -QPushButton#btn_new_request:focus { - border: 2px solid #107c10; -} - -/* Secondary Action Buttons */ -QPushButton#btn_save { - background-color: #ff8c00; - font-weight: 500; -} - -QPushButton#btn_save:hover { - background-color: #ffa500; -} - -QPushButton#btn_save:pressed { - background-color: #e67300; -} - -QPushButton#btn_save:focus { - border: 2px solid #ff8c00; -} - -/* Primary Action Button */ -QPushButton#btn_send { - background-color: #0078d4; - font-weight: 600; - min-width: 100px; -} - -QPushButton#btn_send:hover { - background-color: #106ebe; -} - -QPushButton#btn_send:pressed { - background-color: #005a9e; -} - -QPushButton#btn_send:focus { - border: 2px solid #0078d4; -} - -/* Small utility buttons - individually styled for Qt5 compatibility */ -QPushButton#btn_add_param { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -QPushButton#btn_remove_param { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -QPushButton#btn_add_header { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -QPushButton#btn_remove_header { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -QPushButton#btn_add_body { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -QPushButton#btn_remove_body { - background-color: #3e3e42; - color: #cccccc; - min-width: 60px; - padding: 6px 12px; - font-size: 12px; - border-radius: 4px; -} - -/* Hover states */ -QPushButton#btn_add_param:hover, -QPushButton#btn_remove_param:hover, -QPushButton#btn_add_header:hover, -QPushButton#btn_remove_header:hover, -QPushButton#btn_add_body:hover, -QPushButton#btn_remove_body:hover { - background-color: #4e4e52; - color: #ffffff; -} - -/* Pressed states */ -QPushButton#btn_add_param:pressed, -QPushButton#btn_remove_param:pressed, -QPushButton#btn_add_header:pressed, -QPushButton#btn_remove_header:pressed, -QPushButton#btn_add_body:pressed, -QPushButton#btn_remove_body:pressed { - background-color: #5e5e5e; -} - -/* ======================================== - SPLITTER STYLING - ======================================== */ - -QSplitter::handle { - background-color: #3e3e42; - border-radius: 2px; -} - -QSplitter::handle:horizontal { - width: 4px; - margin: 2px 0; -} - -QSplitter::handle:vertical { - height: 4px; - margin: 0 2px; -} - -QSplitter::handle:hover { - background-color: #0078d4; -} - -QSplitter::handle:pressed { - background-color: #005a9e; -} - -/* ======================================== - MODERN SCROLLBAR STYLING - ======================================== */ - -QScrollBar:vertical { - border: none; - background-color: #2d2d30; - width: 12px; - margin: 0; - border-radius: 6px; -} - -QScrollBar::handle:vertical { - background-color: #4e4e52; - border-radius: 6px; - min-height: 24px; - margin: 2px; - border: none; -} - -QScrollBar::handle:vertical:hover { - background-color: #5e5e5e; -} - -QScrollBar::handle:vertical:pressed { - background-color: #0078d4; -} - -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height: 0px; -} - -QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; -} - -QScrollBar:horizontal { - border: none; - background-color: #2d2d30; - height: 12px; - margin: 0; - border-radius: 6px; -} - -QScrollBar::handle:horizontal { - background-color: #4e4e52; - border-radius: 6px; - min-width: 24px; - margin: 2px; - border: none; -} - -QScrollBar::handle:horizontal:hover { - background-color: #5e5e5e; -} - -QScrollBar::handle:horizontal:pressed { - background-color: #0078d4; -} - -QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - width: 0px; -} - -QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { - background: none; -} - -/* Scrollbar for specific widgets */ -/* Simplified scrollbars for Qt5 compatibility */ -QTextEdit QScrollBar:vertical { - background-color: #252526; - width: 10px; -} - -QListWidget QScrollBar:vertical { - background-color: #252526; - width: 10px; -} - -QTableWidget QScrollBar:vertical { - background-color: #252526; - width: 10px; -} - -/* Simplified scrollbar handles for Qt5 compatibility */ -QTextEdit QScrollBar::handle:vertical { - background-color: #3e3e42; - border-radius: 5px; - min-height: 20px; - margin: 1px; -} - -QListWidget QScrollBar::handle:vertical { - background-color: #3e3e42; - border-radius: 5px; - min-height: 20px; - margin: 1px; -} - -QTableWidget QScrollBar::handle:vertical { - background-color: #3e3e42; - border-radius: 5px; - min-height: 20px; - margin: 1px; -} - -/* Simplified scrollbar hover for Qt5 compatibility */ -QTextEdit QScrollBar::handle:vertical:hover { - background-color: #4e4e52; -} - -QListWidget QScrollBar::handle:vertical:hover { - background-color: #4e4e52; -} - -QTableWidget QScrollBar::handle:vertical:hover { - background-color: #4e4e52; -} - -/* ======================================== - MODERN DIALOG AND MESSAGE BOX STYLING - ======================================== */ - -QMessageBox { - background-color: #2d2d30; - color: #cccccc; - border: 1px solid #4e4e52; - border-radius: 8px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; -} - -QMessageBox QPushButton { - background-color: #0078d4; - color: #ffffff; - border: none; - border-radius: 6px; - padding: 8px 16px; - font-size: 13px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - min-width: 80px; - min-height: 32px; - letter-spacing: 0.1px; -} - -QMessageBox QPushButton:hover { - background-color: #106ebe; -} - -QMessageBox QPushButton:pressed { - background-color: #005a9e; -} - -QMessageBox QLabel { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - background-color: transparent; -} - -QMessageBox QTextEdit { - background-color: #1e1e1e; - color: #cccccc; - border: 1px solid #3e3e42; - border-radius: 6px; - font-size: 13px; - font-family: 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; -} - -/* ======================================== - QDIALOG STYLING - ======================================== */ - -QDialog { - background-color: #1e1e1e; - color: #cccccc; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - font-size: 13px; -} - -/* ======================================== - GROUP BOX STYLING (matches QFrame) - ======================================== */ - -QGroupBox { - background-color: #252526; - border: 1px solid #3e3e42; - border-radius: 6px; - margin-top: 14px; - padding: 10px 12px 10px 12px; - font-size: 13px; - font-weight: 400; - color: #cccccc; -} - -QGroupBox::title { - subcontrol-origin: margin; - subcontrol-position: top left; - left: 12px; - top: 0px; - padding: 3px 12px; - color: #ffffff; - font-size: 14px; - font-weight: 500; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - background-color: #252526; - border-radius: 4px; -} - -/* ======================================== - CHECKBOX STYLING - ======================================== */ - -QCheckBox { - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, Arial, sans-serif; - spacing: 8px; - background-color: transparent; - padding: 2px 0; -} - -QCheckBox::indicator { - width: 18px; - height: 18px; - border: 1px solid #3e3e42; - border-radius: 3px; - background-color: #1e1e1e; -} - -QCheckBox::indicator:hover { - border-color: #4e4e52; -} - -QCheckBox::indicator:checked { - background-color: #0078d4; - border-color: #0078d4; -} - -QCheckBox::indicator:checked:hover { - background-color: #106ebe; -} - -QCheckBox::indicator:disabled { - background-color: #2d2d30; - border-color: #3e3e42; -} - -/* ======================================== - SPINBOX STYLING (matches QLineEdit) - ======================================== */ - -QSpinBox { - border: 1px solid #3e3e42; - border-radius: 4px; - padding: 8px 12px; - background-color: #1e1e1e; - color: #cccccc; - font-size: 13px; - font-family: 'Segoe UI', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Consolas', 'Courier New', monospace; - selection-background-color: #0078d4; - selection-color: #ffffff; - min-height: 20px; -} - -QSpinBox:focus { - border: 2px solid #0078d4; - padding: 7px 11px; -} - -QSpinBox:hover { - border-color: #4e4e52; -} - -QSpinBox:disabled { - background-color: #2d2d30; - color: #6a6a6a; - border-color: #3e3e42; -} - -QSpinBox::up-button, QSpinBox::down-button { - border: none; - background-color: #2d2d30; - width: 20px; - border-radius: 2px; - margin: 1px; -} - -QSpinBox::up-button:hover, QSpinBox::down-button:hover { - background-color: #3e3e42; -} - -QSpinBox::up-button:pressed { - background-color: #0078d4; -} - -QSpinBox::down-button:pressed { - background-color: #0078d4; -} - -QSpinBox::up-arrow { - image: none; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-bottom: 5px solid #cccccc; -} - -QSpinBox::down-arrow { - image: none; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 5px solid #cccccc; -} - -QSpinBox::up-arrow:hover, QSpinBox::down-arrow:hover { - /* Simplified for Qt5 — changed border-color is sufficient */ -} - -/* ======================================== - DIALOG BUTTON BOX STYLING - ======================================== */ - -QDialogButtonBox { - background-color: transparent; - button-layout: 2; /* Qt::WindowsLayout */ -} - -QDialogButtonBox QPushButton { - min-width: 90px; -} - -/* ======================================== - RESPONSE TEXT EDITORS STYLING - ======================================== */ - -QTextEdit#textEdit_response_headers, -QTextEdit#textEdit_response_body, -QTextEdit#textEdit_body { - background-color: #1e1e1e; - color: #cccccc; - selection-background-color: #0078d4; - selection-color: #ffffff; - font-size: 13px; - font-family: 'Consolas', 'Courier New', monospace; - padding: 12px; - border: 1px solid #3e3e42; - border-radius: 6px; -} - -QTextEdit#textEdit_response_headers:focus, -QTextEdit#textEdit_response_body:focus, -QTextEdit#textEdit_body:focus { - border: 2px solid #0078d4; - padding: 11px; -} - -/* Read-only text editors - simplified for Qt5 */ -/* Note: Qt5 has limited support for :read-only pseudo-class */ - -/* ======================================== - FINAL VISUAL ENHANCEMENTS - ======================================== */ - -/* Focus indicators for all interactive elements */ -QLineEdit:focus, -QComboBox:focus, -QTextEdit:focus, -QTableWidget:focus, -QListWidget:focus, -QTabBar::tab:selected { - outline: 2px solid #0078d4; - outline-offset: 1px; -} - -/* Enhanced disabled state styling */ -*:disabled { - color: #6a6a6a; - opacity: 0.7; -} - -/* Smooth transitions for better UX - simplified for Qt5 */ -/* Note: Qt5 has limited transition support */ - -/* High contrast mode support - simplified for Qt5 */ -/* Note: Qt5 doesn't support @media queries - this is for documentation only */ - -/* Special styling for URL input field */ -QLineEdit#lineEdit_url { - font-family: 'Consolas', 'Courier New', 'SF Mono', monospace; - font-size: 14px; - letter-spacing: 0.2px; -} - -/* Enhanced search field styling */ -QLineEdit#lineEdit_search { - border-radius: 20px; - padding-left: 16px; - font-style: normal; -} - -/* Method combobox specific styling */ -QComboBox#cmb_method { - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - min-width: 80px; -} - diff --git a/samples/networkrequesttool/main.cpp b/samples/networkrequesttool/main.cpp index 22c61d4..f6f1d60 100644 --- a/samples/networkrequesttool/main.cpp +++ b/samples/networkrequesttool/main.cpp @@ -2,6 +2,7 @@ #include #include #include "networkrequesttool.h" +#include "thememanager.h" int main(int argc, char *argv[]) { @@ -11,6 +12,11 @@ int main(int argc, char *argv[]) QApplication a(argc, argv); a.setWindowIcon(QIcon("resources/networktool.ico")); + + // Apply the persisted theme (Light/Dark/System) before any widget is + // shown so the very first paint is already themed. + ThemeManager::applyStoredMode(); + QtNetworkRequest::NetworkRequestTool w; w.show(); return a.exec(); diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index c4681bd..dcccb5e 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -42,6 +42,7 @@ #include #include "networkrequestmanager.h" #include "networkreply.h" +#include "thememanager.h" using namespace QtNetworkRequest; @@ -112,6 +113,11 @@ void NetworkRequestTool::unInitialize() void NetworkRequestTool::initializeUI() { + // Apply the persisted theme before any child widgets are created so the + // first paint is already themed (the QSS lives on qApp). + m_theme = new ThemeManager(this); + m_theme->setMode(m_theme->mode()); + // Set initial splitter ratio ui.splitter->setStretchFactor(0, 1); // History list ui.splitter->setStretchFactor(1, 3); // Request area @@ -257,6 +263,13 @@ void NetworkRequestTool::initializeUI() m_btnManageEnv->setToolTip("Manage environments"); m_btnManageEnv->setFixedWidth(40); toolbarLayout->addWidget(m_btnManageEnv); + + // --- Theme toggle (Light/Dark) --- + m_btnTheme = new QPushButton(QStringLiteral("\xe2\x98\xbe")); // ☾ crescent + m_btnTheme->setObjectName("btn_theme"); + m_btnTheme->setToolTip("Toggle light/dark theme"); + m_btnTheme->setFixedWidth(40); + toolbarLayout->addWidget(m_btnTheme); } // --- Response toolbar (injected into response body tab) --- @@ -297,6 +310,10 @@ void NetworkRequestTool::initializeConnections() if (m_btnManageEnv) connect(m_btnManageEnv, &QPushButton::clicked, this, &NetworkRequestTool::onManageEnvironments); + // Theme toggle + if (m_btnTheme && m_theme) + connect(m_btnTheme, &QPushButton::clicked, m_theme, &ThemeManager::toggle); + // Collection tree if (m_collectionTree) connect(m_collectionTree, &QTreeWidget::itemClicked, @@ -675,9 +692,11 @@ void NetworkRequestTool::onSendRequest() this, &NetworkRequestTool::onResponse); clearResponse(); - appendToResponseBody("Sending request...\n", QColor(0, 120, 212)); - appendToResponseBody("URL: " + raw + "\n", QColor(204, 204, 204)); - appendToResponseBody("Method: " + currentMethod + "\n\n", QColor(204, 204, 204)); + const QColor accent = m_theme ? m_theme->accent() : QColor(0x43, 0x61, 0xee); + const QColor muted = palette().color(QPalette::WindowText); + appendToResponseBody("Sending request...\n", accent); + appendToResponseBody("URL: " + raw + "\n", muted); + appendToResponseBody("Method: " + currentMethod + "\n\n", muted); } else { @@ -929,19 +948,21 @@ void NetworkRequestTool::onResponse(QSharedPointer(ui.textEdit_response_body->document()); m_lastResponseBody = rsp->body; - appendToResponseBody(rsp->body, QColor(16, 124, 16)); + appendToResponseBody(rsp->body, palette().color(QPalette::WindowText)); } else { m_lastResponseBody = rsp->body; - appendToResponseBody(rsp->body, QColor(16, 124, 16)); + appendToResponseBody(rsp->body, palette().color(QPalette::WindowText)); } displayResponseCookies(rsp->cookies); } else { - appendToResponseBody("Error: \n" + rsp->error.message, QColor(232, 17, 35)); + appendToResponseBody("Error: \n" + rsp->error.message, + m_theme && m_theme->isDark() ? QColor(0xf8, 0x71, 0x71) + : QColor(0xdc, 0x26, 0x26)); } // Item 2: show status code, time, size @@ -997,14 +1018,15 @@ bool NetworkRequestTool::isOctetStreamResponse(const QMapaccent() : QColor(0x43, 0x61, 0xee); + const QColor muted = palette().color(QPalette::WindowText); + appendToResponseHeaders("Response Headers:\n", accent); + appendToResponseHeaders("================\n", accent); for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) { QString headerLine = QString("%1: %2\n").arg(QString::fromUtf8(it.key())).arg(QString::fromUtf8(it.value())); - appendToResponseHeaders(headerLine, QColor(204, 204, 204)); + appendToResponseHeaders(headerLine, muted); } } diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 2622821..7387efa 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -15,9 +15,11 @@ class QSyntaxHighlighter; class QTreeWidget; class QTreeWidgetItem; +class ThemeManager; #ifdef QT_MTNETWORK_UNIT_TEST class TestQtRequester; +class TestTheme; #endif namespace QtNetworkRequest @@ -181,6 +183,7 @@ private slots: private: #ifdef QT_MTNETWORK_UNIT_TEST friend class ::TestQtRequester; + friend class ::TestTheme; #endif Ui::networkClass ui; QString currentMethod; @@ -212,6 +215,10 @@ private slots: QWidget *m_collectionToolbar{nullptr}; Collection m_collection; // in-memory collection model QString m_collectionPath; // file path for persistence + + // Theme (light/dark/system runtime switching) + ThemeManager *m_theme{nullptr}; + QPushButton *m_btnTheme{nullptr}; }; } // namespace QtNetworkRequest diff --git a/samples/networkrequesttool/networkrequesttool.pro b/samples/networkrequesttool/networkrequesttool.pro index f5ce73b..fcad037 100644 --- a/samples/networkrequesttool/networkrequesttool.pro +++ b/samples/networkrequesttool/networkrequesttool.pro @@ -18,7 +18,8 @@ INCLUDEPATH += . \ # Input HEADERS += networkrequesttool.h \ jsonsyntaxhighlighter.h \ - xmlsyntaxhighlighter.h + xmlsyntaxhighlighter.h \ + thememanager.h FORMS += NetworkRequestTool.ui SOURCES += main.cpp networkrequesttool.cpp \ jsonsyntaxhighlighter.cpp \ diff --git a/samples/networkrequesttool/resources/styles/precision-dark.qss b/samples/networkrequesttool/resources/styles/precision-dark.qss new file mode 100644 index 0000000..8edee9e --- /dev/null +++ b/samples/networkrequesttool/resources/styles/precision-dark.qss @@ -0,0 +1,710 @@ +/* ================================================================ + Precision Workshop — Qt 5.15 Dark Theme + 完全避免黑字:每种控件显式设置 color + ================================================================ */ + +/* --- QMainWindow / QDialog --- */ +QMainWindow, QDialog { + background-color: #12121a; + color: #e4e3ed; + font-family: "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +/* --- QFrame --- */ +QFrame { + background-color: #1a1a26; + border: 1px solid #2a2a3a; + border-radius: 7px; +} + +QFrame#urlFrame, QFrame#tableFrame, QFrame#controlFrame, +QFrame#frame_request_toolbar { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 10px; +} + +QWidget#frame_history, +QWidget#widget_history { + background: transparent; + border: none; +} + +QFrame#statusFrame { + background-color: #1e2048; + border: 1px solid #363648; + border-radius: 5px; +} + +/* ================================================================ + QLabel + ================================================================ */ +QLabel { color: #e4e3ed; background: transparent; } + +QLabel#label_history, +QLabel#label, +QLabel#labelTasks { + color: #e4e3ed; + font-size: 15px; + font-weight: 700; +} + +QLabel#sidebarLogoIcon { + background-color: #7b8cff; + color: #12121a; + font-weight: 700; + font-size: 13px; + border-radius: 5px; +} + +QLabel#sidebarLogoText { + color: #e4e3ed; + font-size: 15px; + font-weight: 600; +} + +QLabel#labelSpeed { + color: #7b8cff; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#labelTime { + color: #fbbf24; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#m_labelResponseInfo { + color: #6e6d7e; + font-size: 11px; + background-color: #222233; + padding: 4px 12px; +} + +QLabel#statusBarLabel { + color: #12121a; + font-weight: 600; + font-size: 11px; +} + +/* ================================================================ + QLineEdit / QTextEdit / QPlainTextEdit + ================================================================ */ +QLineEdit, QPlainTextEdit, QTextEdit { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 8px; + color: #e4e3ed; + font-size: 13px; + selection-background-color: #7b8cff; + selection-color: #12121a; +} + +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus { border-color: #7b8cff; } + +QLineEdit[readOnly="true"], QTextEdit[readOnly="true"] { + background-color: #161621; + color: #a8a7b8; +} + +QLineEdit#lineEdit_url { + font-family: "Consolas", "Courier New", monospace; + font-size: 14px; + padding: 6px 12px; +} + +QLineEdit#lineEdit_search, +QLineEdit#m_leResponseSearch { + border-radius: 16px; + padding: 5px 12px; + background-color: #161621; + font-size: 12px; +} + +QTextEdit#textEdit_response_body, +QTextEdit#textEdit_response_headers, +QTextEdit#textEdit_response_cookies, +QTextEdit#textEdit_body { + background-color: #161621; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + border: none; + color: #e4e3ed; +} + +QPlainTextEdit#plainTextEditUrls { + background-color: #161621; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + color: #e4e3ed; +} + +/* ================================================================ + QComboBox + ================================================================ */ +QComboBox { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 28px 5px 10px; + color: #e4e3ed; + font-size: 13px; + min-width: 80px; +} + +QComboBox:focus { border-color: #7b8cff; } + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border: none; +} + +QComboBox QAbstractItemView { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 5px; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; + padding: 2px; +} + +QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 3px; + color: #e4e3ed; +} + +QComboBox QAbstractItemView::item:hover { background-color: #242438; } +QComboBox QAbstractItemView::item:selected { background-color: #2a2a48; color: #7b8cff; } + +QComboBox#cmb_method { + font-weight: 700; + min-width: 100px; + color: #7b8cff; + font-size: 14px; + padding: 4px 10px; +} + +QComboBox#cmb_environment { font-size: 12px; color: #a8a7b8; min-width: 90px; } +QComboBox#cmb_body_type { font-size: 12px; font-weight: 500; color: #e4e3ed; } + +/* ================================================================ + QPushButton + ================================================================ */ +QPushButton { + background-color: #7b8cff; + color: #12121a; + border: none; + border-radius: 5px; + padding: 6px 14px; + font-size: 13px; + font-weight: 600; +} + +QPushButton:hover { background-color: #8f9dff; } +QPushButton:pressed { background-color: #6a7cee; } +QPushButton:disabled { background-color: #4a4a5a; color: #6e6d7e; } + +QPushButton#btn_send { + background-color: #7b8cff; + color: #12121a; + font-weight: 700; + padding: 6px 18px; +} +QPushButton#btn_send:hover { background-color: #8f9dff; } + +QPushButton#btn_new_request, +QPushButton#btnStart, +QPushButton#btnAddTasks { + background-color: #34d399; + color: #12121a; +} +QPushButton#btn_new_request:hover, +QPushButton#btnStart:hover, +QPushButton#btnAddTasks:hover { background-color: #2bb87a; } + +QPushButton#btn_save { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + font-weight: 600; +} +QPushButton#btn_save:hover { background-color: #242438; color: #e4e3ed; } + +QPushButton#btnCancel { + background-color: #f87171; + color: #12121a; +} +QPushButton#btnCancel:hover { background-color: #e55555; } + +QPushButton#btnDelete { + background-color: #fbbf24; + color: #12121a; +} +QPushButton#btnDelete:hover { background-color: #e0a800; } + +QPushButton#btn_add_param, QPushButton#btn_remove_param, +QPushButton#btn_add_header, QPushButton#btn_remove_header, +QPushButton#btn_add_body, QPushButton#btn_remove_body, +QPushButton#btn_settings, QPushButton#btn_manage_env, +QPushButton#btn_binary_browse { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + font-weight: 500; + padding: 4px 10px; + font-size: 12px; +} + +QPushButton#btn_add_param:hover, QPushButton#btn_remove_param:hover, +QPushButton#btn_add_header:hover, QPushButton#btn_remove_header:hover, +QPushButton#btn_add_body:hover, QPushButton#btn_remove_body:hover, +QPushButton#btn_settings:hover, QPushButton#btn_manage_env:hover, +QPushButton#btn_binary_browse:hover { + background-color: #242438; + color: #e4e3ed; +} + +/* Collection toolbar buttons (injected) */ +QPushButton#btn_coll { + background-color: transparent; + color: #a8a7b8; + border: none; + border-radius: 4px; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll:hover { + background-color: #242438; + color: #7b8cff; +} + +QPushButton#btn_coll_secondary { + background-color: transparent; + color: #6e6d7e; + border: none; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll_secondary:hover { + background-color: #242438; + color: #e4e3ed; +} + +QWidget#collectionToolbar { background: transparent; } + +/* Sidebar tabs (历史 / 收藏夹) */ +QPushButton#tabHistory, QPushButton#tabCollections { + background: transparent; + color: #6e6d7e; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-weight: 600; + font-size: 11px; + padding: 6px 16px; +} +QPushButton#tabHistory:hover, QPushButton#tabCollections:hover { + color: #a8a7b8; +} +QPushButton#tabHistory:checked, QPushButton#tabCollections:checked { + color: #7b8cff; + border-bottom: 2px solid #7b8cff; + background: transparent; +} + +QWidget#sidebarTabs { + background: transparent; + border-bottom: 1px solid #2a2a3a; +} + +/* Response area tab buttons (Body/Headers/Cookies) */ +QPushButton#tabResp { + background: transparent; + color: #6e6d7e; + border: none; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 4px 10px; +} +QPushButton#tabResp:hover { color: #a8a7b8; background-color: #242438; } +QPushButton#tabResp:checked { + color: #7b8cff; + background-color: #1e2048; +} + +/* Response toolbar buttons (injected) */ +QPushButton#btn_resp_nav { + background-color: #161621; + color: #a8a7b8; + border: 1px solid #363648; + border-radius: 4px; + font-weight: 700; + font-size: 14px; + padding: 0; +} +QPushButton#btn_resp_nav:hover { background-color: #1e2048; color: #7b8cff; border-color: #7b8cff; } + +QPushButton#btn_resp_secondary { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 3px 10px; +} +QPushButton#btn_resp_secondary:hover { + background-color: #1e2048; + color: #e4e3ed; + border-color: #7b8cff; +} + +/* Pretty toggle switch */ +QCheckBox#chk_resp_pretty { + color: #a8a7b8; + font-size: 12px; + spacing: 8px; +} +QCheckBox#chk_resp_pretty::indicator { + width: 32px; + height: 18px; + border: none; + border-radius: 9px; + background-color: #363648; +} +QCheckBox#chk_resp_pretty::indicator:hover { + background-color: #4a4a5a; +} +QCheckBox#chk_resp_pretty::indicator:checked { + background-color: #7b8cff; +} + +/* ================================================================ + QTabWidget / QTabBar + ================================================================ */ +QTabWidget::pane { border: none; background-color: #1a1a26; } + +QTabBar::tab { + background: transparent; + color: #6e6d7e; + padding: 6px 10px; + font-size: 13px; + font-weight: 500; + border: none; + border-bottom: 2px solid transparent; + min-width: 60px; +} + +QTabBar::tab:hover { color: #a8a7b8; } + +QTabBar::tab:selected { + color: #7b8cff; + border-bottom: 2px solid #7b8cff; +} + +/* ================================================================ + QTableWidget + ================================================================ */ +QTableWidget { + background-color: #1a1a26; + border: none; + gridline-color: #2a2a3a; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; +} + +QTableWidget::item { padding: 3px 8px; color: #e4e3ed; } +QTableWidget::item:hover { background-color: #242438; } +QTableWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QTableView + ================================================================ */ +QTableView { + background-color: #1a1a26; + border: none; + gridline-color: #2a2a3a; + color: #e4e3ed; + selection-background-color: #2a2a48; + selection-color: #e4e3ed; + outline: none; + alternate-background-color: #222233; +} + +QTableView::item { padding: 6px 12px; color: #e4e3ed; } +QTableView::item:hover { background-color: #242438; } +QTableView::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QHeaderView + ================================================================ */ +QHeaderView::section { + background-color: #222233; + color: #6e6d7e; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + border: none; + border-bottom: 1px solid #363648; +} + +QHeaderView::section:hover { color: #e4e3ed; } + +QTableCornerButton::section { + background-color: #222233; + border: none; + border-bottom: 1px solid #363648; + border-right: 1px solid #363648; +} + +/* ================================================================ + QListWidget + ================================================================ */ +QListWidget { + background-color: #1a1a26; + border: none; + color: #e4e3ed; + outline: none; +} + +QListWidget::item { + padding: 8px 12px; + border-radius: 3px; + margin: 1px 4px; + color: #e4e3ed; +} + +QListWidget::item:hover { background-color: #242438; } +QListWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +/* ================================================================ + QTreeWidget + ================================================================ */ +QTreeWidget { + background-color: #1a1a26; + border: none; + color: #e4e3ed; + outline: none; +} + +QTreeWidget::item { + padding: 5px 8px; + border-radius: 3px; + margin: 1px 4px; + color: #e4e3ed; +} + +QTreeWidget::item:hover { background-color: #242438; } +QTreeWidget::item:selected { background-color: #2a2a48; color: #7b8cff; } + +QTreeWidget::branch:has-children:!has-siblings:closed, +QTreeWidget::branch:closed:has-children:has-siblings { border-image: none; } + +/* ================================================================ + QProgressBar + ================================================================ */ +QProgressBar { + background-color: #161621; + border: none; + border-radius: 3px; + height: 6px; + text-align: center; + font-size: 1px; + color: transparent; +} + +QProgressBar::chunk { background-color: #7b8cff; border-radius: 3px; } + +/* ================================================================ + QSplitter + ================================================================ */ +QSplitter::handle { background-color: #2a2a3a; } +QSplitter::handle:hover { background-color: #7b8cff; } +QSplitter::handle:horizontal { width: 4px; } +QSplitter::handle:vertical { height: 4px; } + +/* ================================================================ + QMenuBar / QMenu + ================================================================ */ +QMenuBar { + background-color: #222233; + border-bottom: 1px solid #2a2a3a; + padding: 2px 8px; + color: #a8a7b8; + font-size: 13px; +} + +QMenuBar::item { padding: 4px 10px; border-radius: 3px; color: #a8a7b8; } +QMenuBar::item:selected { background-color: #242438; color: #e4e3ed; } + +QMenu { + background-color: #1a1a26; + border: 1px solid #363648; + border-radius: 7px; + padding: 4px; + color: #e4e3ed; +} + +QMenu::item { padding: 6px 28px 6px 12px; border-radius: 3px; color: #e4e3ed; } +QMenu::item:selected { background-color: #2a2a48; color: #7b8cff; } +QMenu::separator { height: 1px; background-color: #2a2a3a; margin: 4px 8px; } + +/* ================================================================ + QStatusBar + ================================================================ */ +QStatusBar { + background-color: #7b8cff; + color: #12121a; + font-size: 11px; + font-weight: 700; + padding: 3px 14px; + min-height: 22px; + border-top: 2px solid #12121a; +} + +QStatusBar::item { border: none; } +QSizeGrip { background: transparent; width: 12px; height: 12px; } + +/* ================================================================ + QScrollBar + ================================================================ */ +QScrollBar:vertical { background: transparent; width: 8px; margin: 0; } +QScrollBar::handle:vertical { + background-color: #363648; + border-radius: 4px; + min-height: 30px; +} +QScrollBar::handle:vertical:hover { background-color: #4a4a5a; } +QScrollBar::add-line:vertical { height: 0; } +QScrollBar::sub-line:vertical { height: 0; } + +QScrollBar:horizontal { background: transparent; height: 8px; } +QScrollBar::handle:horizontal { + background-color: #363648; + border-radius: 4px; + min-width: 30px; +} +QScrollBar::handle:horizontal:hover { background-color: #4a4a5a; } +QScrollBar::add-line:horizontal { width: 0; } +QScrollBar::sub-line:horizontal { width: 0; } + +/* ================================================================ + QGroupBox + ================================================================ */ +QGroupBox { + background-color: #1a1a26; + border: 1px solid #2a2a3a; + border-radius: 7px; + margin-top: 14px; + padding-top: 16px; + color: #e4e3ed; + font-weight: 600; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px 12px; + background-color: #222233; + border-radius: 4px; + color: #a8a7b8; + font-size: 12px; + font-weight: 600; +} + +/* ================================================================ + QCheckBox + ================================================================ */ +QCheckBox { + color: #e4e3ed; + spacing: 8px; + font-size: 13px; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #363648; + border-radius: 3px; + background: transparent; +} +QCheckBox::indicator:hover { border-color: #7b8cff; } +QCheckBox::indicator:checked { background-color: #7b8cff; border-color: #7b8cff; } + +/* ================================================================ + QSpinBox + ================================================================ */ +QSpinBox { + background-color: #1e1e2e; + border: 1px solid #363648; + border-radius: 5px; + padding: 5px 10px; + color: #e4e3ed; + font-size: 13px; +} + +QSpinBox:focus { border-color: #7b8cff; } + +QSpinBox::up-button, QSpinBox::down-button { + width: 20px; + border: none; + background-color: #222233; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { background-color: #242438; } + +/* ================================================================ + QToolTip + ================================================================ */ +QToolTip { + background-color: #e8e6e1; + color: #12121a; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; +} + +/* QDialogButtonBox / QMessageBox */ +QDialogButtonBox QPushButton { + padding: 6px 18px; + font-size: 13px; + font-weight: 600; + border-radius: 5px; + min-width: 80px; +} + +QPushButton[text="OK"], +QPushButton[text="确定"], +QPushButton[text="Save"] { + background-color: #7b8cff; + color: #12121a; + border: none; +} +QPushButton[text="OK"]:hover, +QPushButton[text="确定"]:hover, +QPushButton[text="Save"]:hover { background-color: #8f9dff; } + +QPushButton[text="Cancel"], +QPushButton[text="取消"] { + background-color: transparent; + color: #a8a7b8; + border: 1px solid #363648; +} +QPushButton[text="Cancel"]:hover, +QPushButton[text="取消"]:hover { background-color: #242438; color: #e4e3ed; } + +QMessageBox { background-color: #12121a; } +QMessageBox QLabel { color: #e4e3ed; font-size: 13px; } diff --git a/samples/networkrequesttool/resources/styles/precision-light.qss b/samples/networkrequesttool/resources/styles/precision-light.qss new file mode 100644 index 0000000..6d0eeab --- /dev/null +++ b/samples/networkrequesttool/resources/styles/precision-light.qss @@ -0,0 +1,780 @@ +/* ================================================================ + Precision Workshop — Qt 5.15 Light Theme + 完全匹配 HTML 原型 qt-requester-prototype.html + ================================================================ */ + +/* --- QMainWindow / QDialog --- */ +QMainWindow, QDialog { + background-color: #f8f7f4; + color: #1a1a24; + font-family: "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +/* --- QFrame --- */ +QFrame { + background-color: #ffffff; + border: 1px solid #e8e6e1; + border-radius: 7px; +} + +QFrame#urlFrame, QFrame#tableFrame, QFrame#controlFrame, +QFrame#frame_request_toolbar { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 10px; +} + +QWidget#frame_history, +QWidget#widget_history { + background: transparent; + border: none; +} + +QFrame#statusFrame { + background-color: #eef0ff; + border: 1px solid #dbd9d2; + border-radius: 5px; +} + +/* ================================================================ + QLabel — 显式 color 防黑字 + ================================================================ */ +QLabel { + color: #1a1a24; + background: transparent; +} + +QLabel#label_history, +QLabel#label, +QLabel#labelTasks { + color: #1a1a24; + font-size: 15px; + font-weight: 700; +} + +QLabel#sidebarLogoIcon { + background-color: #4361ee; + color: #ffffff; + font-weight: 700; + font-size: 13px; + border-radius: 5px; +} + +QLabel#sidebarLogoText { + color: #1a1a24; + font-size: 15px; + font-weight: 600; +} + +QLabel#labelSpeed { + color: #4361ee; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#labelTime { + color: #d97706; + font-weight: 700; + font-family: "Consolas", "Courier New", monospace; +} + +QLabel#m_labelResponseInfo { + color: #8f8e99; + font-size: 11px; + background-color: #fafaf8; + padding: 4px 12px; +} + +QLabel#statusBarLabel { + color: #ffffff; + font-weight: 600; + font-size: 11px; +} + +/* ================================================================ + QLineEdit / QTextEdit / QPlainTextEdit — 显式 color + ================================================================ */ +QLineEdit, QPlainTextEdit, QTextEdit { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 8px; + color: #1a1a24; + font-size: 13px; + selection-background-color: #4361ee; + selection-color: #ffffff; +} + +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus { + border-color: #4361ee; +} + +QLineEdit[readOnly="true"], QTextEdit[readOnly="true"] { + background-color: #f1f0ec; + color: #5e5d6b; +} + +QLineEdit#lineEdit_url { + font-family: "Consolas", "Courier New", monospace; + font-size: 14px; + padding: 6px 12px; +} + +QLineEdit#lineEdit_search, +QLineEdit#m_leResponseSearch { + border-radius: 16px; + padding: 5px 12px; + background-color: #f1f0ec; + font-size: 12px; +} + +QTextEdit#textEdit_response_body, +QTextEdit#textEdit_response_headers, +QTextEdit#textEdit_response_cookies, +QTextEdit#textEdit_body { + background-color: #f1f0ec; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + border: none; + color: #1a1a24; +} + +QPlainTextEdit#plainTextEditUrls { + background-color: #f1f0ec; + font-family: "Consolas", "Courier New", monospace; + font-size: 13px; + color: #1a1a24; +} + +/* ================================================================ + QComboBox — 显式 color + ================================================================ */ +QComboBox { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 28px 5px 10px; + color: #1a1a24; + font-size: 13px; + min-width: 80px; +} + +QComboBox:focus { border-color: #4361ee; } + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border: none; +} + +QComboBox QAbstractItemView { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + selection-background-color: #e8e7f5; + color: #1a1a24; + selection-color: #1a1a24; + outline: none; + padding: 2px; +} + +QComboBox QAbstractItemView::item { + padding: 8px 12px; + border-radius: 3px; + color: #1a1a24; +} + +QComboBox QAbstractItemView::item:hover { background-color: #eeedf5; } +QComboBox QAbstractItemView::item:selected { background-color: #e8e7f5; color: #4361ee; } + +QComboBox#cmb_method { + font-weight: 700; + min-width: 100px; + color: #4361ee; + font-size: 14px; + padding: 4px 10px; +} + +QComboBox#cmb_environment { + font-size: 12px; + color: #5e5d6b; + min-width: 90px; +} + +QComboBox#cmb_body_type { + font-size: 12px; + font-weight: 500; + color: #1a1a24; +} + +/* ================================================================ + QPushButton — 显式 color + 缩小 padding + ================================================================ */ +QPushButton { + background-color: #4361ee; + color: #ffffff; + border: none; + border-radius: 5px; + padding: 6px 14px; + font-size: 13px; + font-weight: 600; +} + +QPushButton:hover { background-color: #3451d1; } +QPushButton:pressed { background-color: #2a42b0; } +QPushButton:disabled { + background-color: #dbd9d2; + color: #8f8e99; +} + +QPushButton#btn_send { + background-color: #4361ee; + color: #ffffff; + font-weight: 700; + padding: 6px 18px; +} +QPushButton#btn_send:hover { background-color: #3451d1; } + +QPushButton#btn_new_request, +QPushButton#btnStart, +QPushButton#btnAddTasks { + background-color: #2d8a56; + color: #ffffff; +} +QPushButton#btn_new_request:hover, +QPushButton#btnStart:hover, +QPushButton#btnAddTasks:hover { background-color: #237a48; } + +QPushButton#btn_save { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + font-weight: 600; +} +QPushButton#btn_save:hover { background-color: #eeedf5; color: #1a1a24; } + +QPushButton#btnCancel { + background-color: #dc2626; + color: #ffffff; +} +QPushButton#btnCancel:hover { background-color: #c42121; } + +QPushButton#btnDelete { + background-color: #d97706; + color: #ffffff; +} +QPushButton#btnDelete:hover { background-color: #c06805; } + +/* Ghost / toolbar buttons */ +QPushButton#btn_add_param, QPushButton#btn_remove_param, +QPushButton#btn_add_header, QPushButton#btn_remove_header, +QPushButton#btn_add_body, QPushButton#btn_remove_body, +QPushButton#btn_settings, QPushButton#btn_manage_env, +QPushButton#btn_binary_browse { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + font-weight: 500; + padding: 4px 10px; + font-size: 12px; +} + +QPushButton#btn_add_param:hover, QPushButton#btn_remove_param:hover, +QPushButton#btn_add_header:hover, QPushButton#btn_remove_header:hover, +QPushButton#btn_add_body:hover, QPushButton#btn_remove_body:hover, +QPushButton#btn_settings:hover, QPushButton#btn_manage_env:hover, +QPushButton#btn_binary_browse:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +/* Collection toolbar buttons (injected) */ +QPushButton#btn_coll { + background-color: transparent; + color: #5e5d6b; + border: none; + border-radius: 4px; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll:hover { + background-color: #eeedf5; + color: #4361ee; +} + +QPushButton#btn_coll_secondary { + background-color: transparent; + color: #8f8e99; + border: none; + font-weight: 500; + font-size: 11px; + padding: 0 8px; +} +QPushButton#btn_coll_secondary:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +QWidget#collectionToolbar { background: transparent; } + +/* Sidebar tabs (历史 / 收藏夹) */ +QPushButton#tabHistory, QPushButton#tabCollections { + background: transparent; + color: #8f8e99; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-weight: 600; + font-size: 11px; + padding: 6px 16px; + text-transform: uppercase; +} +QPushButton#tabHistory:hover, QPushButton#tabCollections:hover { + color: #5e5d6b; +} +QPushButton#tabHistory:checked, QPushButton#tabCollections:checked { + color: #4361ee; + border-bottom: 2px solid #4361ee; + background: transparent; +} + +QWidget#sidebarTabs { + background: transparent; + border-bottom: 1px solid #e8e6e1; +} + +/* Response area tab buttons (Body/Headers/Cookies) */ +QPushButton#tabResp { + background: transparent; + color: #8f8e99; + border: none; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 4px 10px; +} +QPushButton#tabResp:hover { color: #5e5d6b; background-color: #eeedf5; } +QPushButton#tabResp:checked { + color: #4361ee; + background-color: #eef0ff; +} + +/* Response toolbar buttons (injected) */ +QPushButton#btn_resp_nav { + background-color: #f1f0ec; + color: #5e5d6b; + border: 1px solid #dbd9d2; + border-radius: 4px; + font-weight: 700; + font-size: 14px; + padding: 0; +} +QPushButton#btn_resp_nav:hover { background-color: #e8e7f5; color: #4361ee; border-color: #4361ee; } + +QPushButton#btn_resp_secondary { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; + border-radius: 4px; + font-weight: 600; + font-size: 12px; + padding: 3px 10px; +} +QPushButton#btn_resp_secondary:hover { + background-color: #eeedf5; + color: #1a1a24; + border-color: #4361ee; +} + +/* Pretty toggle switch */ +QCheckBox#chk_resp_pretty { + color: #5e5d6b; + font-size: 12px; + spacing: 8px; +} +QCheckBox#chk_resp_pretty::indicator { + width: 32px; + height: 18px; + border: none; + border-radius: 9px; + background-color: #dbd9d2; +} +QCheckBox#chk_resp_pretty::indicator:hover { + background-color: #bfbec5; +} +QCheckBox#chk_resp_pretty::indicator:checked { + background-color: #4361ee; +} + +/* ================================================================ + QTabWidget / QTabBar — 显式 color + ================================================================ */ +QTabWidget::pane { + border: none; + background-color: #ffffff; +} + +QTabBar::tab { + background: transparent; + color: #8f8e99; + padding: 6px 10px; + font-size: 13px; + font-weight: 500; + border: none; + border-bottom: 2px solid transparent; + min-width: 60px; +} + +QTabBar::tab:hover { color: #5e5d6b; } + +QTabBar::tab:selected { + color: #4361ee; + border-bottom: 2px solid #4361ee; +} + +/* ================================================================ + QTableWidget — 显式 color + ================================================================ */ +QTableWidget { + background-color: #ffffff; + border: none; + gridline-color: #e8e6e1; + color: #1a1a24; + selection-background-color: #e8e7f5; + selection-color: #1a1a24; + outline: none; +} + +QTableWidget::item { + padding: 3px 8px; + color: #1a1a24; +} + +QTableWidget::item:hover { background-color: #eeedf5; } +QTableWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QTableView (Downloader) — 显式 color + ================================================================ */ +QTableView { + background-color: #ffffff; + border: none; + gridline-color: #e8e6e1; + color: #1a1a24; + selection-background-color: #e8e7f5; + selection-color: #1a1a24; + outline: none; + alternate-background-color: #fafaf8; +} + +QTableView::item { + padding: 6px 12px; + color: #1a1a24; +} + +QTableView::item:hover { background-color: #eeedf5; } +QTableView::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QHeaderView — 显式 color + ================================================================ */ +QHeaderView::section { + background-color: #fafaf8; + color: #8f8e99; + font-size: 11px; + font-weight: 600; + padding: 6px 12px; + border: none; + border-bottom: 1px solid #dbd9d2; +} + +QHeaderView::section:hover { color: #1a1a24; } + +QTableCornerButton::section { + background-color: #fafaf8; + border: none; + border-bottom: 1px solid #dbd9d2; + border-right: 1px solid #dbd9d2; +} + +/* ================================================================ + QListWidget — 显式 color + ================================================================ */ +QListWidget { + background-color: #ffffff; + border: none; + color: #1a1a24; + outline: none; +} + +QListWidget::item { + padding: 8px 12px; + border-radius: 3px; + margin: 1px 4px; + color: #1a1a24; +} + +QListWidget::item:hover { background-color: #eeedf5; } +QListWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +/* ================================================================ + QTreeWidget — 显式 color + ================================================================ */ +QTreeWidget { + background-color: #ffffff; + border: none; + color: #1a1a24; + outline: none; +} + +QTreeWidget::item { + padding: 5px 8px; + border-radius: 3px; + margin: 1px 4px; + color: #1a1a24; +} + +QTreeWidget::item:hover { background-color: #eeedf5; } +QTreeWidget::item:selected { background-color: #e8e7f5; color: #4361ee; } + +QTreeWidget::branch:has-children:!has-siblings:closed, +QTreeWidget::branch:closed:has-children:has-siblings { + border-image: none; +} + +/* ================================================================ + QProgressBar + ================================================================ */ +QProgressBar { + background-color: #f1f0ec; + border: none; + border-radius: 3px; + height: 6px; + text-align: center; + font-size: 1px; + color: transparent; +} + +QProgressBar::chunk { + background-color: #4361ee; + border-radius: 3px; +} + +/* ================================================================ + QSplitter + ================================================================ */ +QSplitter::handle { background-color: #e8e6e1; } +QSplitter::handle:hover { background-color: #4361ee; } +QSplitter::handle:horizontal { width: 4px; } +QSplitter::handle:vertical { height: 4px; } + +/* ================================================================ + QMenuBar / QMenu — 显式 color + ================================================================ */ +QMenuBar { + background-color: #fafaf8; + border-bottom: 1px solid #e8e6e1; + padding: 2px 8px; + color: #5e5d6b; + font-size: 13px; +} + +QMenuBar::item { + padding: 4px 10px; + border-radius: 3px; + color: #5e5d6b; +} + +QMenuBar::item:selected { + background-color: #eeedf5; + color: #1a1a24; +} + +QMenu { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 7px; + padding: 4px; + color: #1a1a24; +} + +QMenu::item { + padding: 6px 28px 6px 12px; + border-radius: 3px; + color: #1a1a24; +} + +QMenu::item:selected { + background-color: #e8e7f5; + color: #4361ee; +} + +QMenu::separator { + height: 1px; + background-color: #e8e6e1; + margin: 4px 8px; +} + +/* ================================================================ + QStatusBar — branded indigo footer + ================================================================ */ +QStatusBar { + background-color: #4361ee; + color: #ffffff; + font-size: 11px; + font-weight: 700; + padding: 3px 14px; + min-height: 22px; + border-top: 2px solid #2a42b0; +} + +QStatusBar::item { border: none; } +QSizeGrip { background: transparent; width: 12px; height: 12px; } + +/* ================================================================ + QScrollBar + ================================================================ */ +QScrollBar:vertical { background: transparent; width: 8px; margin: 0; } +QScrollBar::handle:vertical { + background-color: #dbd9d2; + border-radius: 4px; + min-height: 30px; +} +QScrollBar::handle:vertical:hover { background-color: #bfbec5; } +QScrollBar::add-line:vertical { height: 0; } +QScrollBar::sub-line:vertical { height: 0; } + +QScrollBar:horizontal { background: transparent; height: 8px; } +QScrollBar::handle:horizontal { + background-color: #dbd9d2; + border-radius: 4px; + min-width: 30px; +} +QScrollBar::handle:horizontal:hover { background-color: #bfbec5; } +QScrollBar::add-line:horizontal { width: 0; } +QScrollBar::sub-line:horizontal { width: 0; } + +/* ================================================================ + QGroupBox — 显式 color + ================================================================ */ +QGroupBox { + background-color: #ffffff; + border: 1px solid #e8e6e1; + border-radius: 7px; + margin-top: 14px; + padding-top: 16px; + color: #1a1a24; + font-weight: 600; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px 12px; + background-color: #fafaf8; + border-radius: 4px; + color: #5e5d6b; + font-size: 12px; + font-weight: 600; +} + +/* ================================================================ + QCheckBox — 显式 color + ================================================================ */ +QCheckBox { + color: #1a1a24; + spacing: 8px; + font-size: 13px; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #dbd9d2; + border-radius: 3px; + background: transparent; +} +QCheckBox::indicator:hover { border-color: #4361ee; } +QCheckBox::indicator:checked { background-color: #4361ee; border-color: #4361ee; } + +/* ================================================================ + QSpinBox — 显式 color + ================================================================ */ +QSpinBox { + background-color: #ffffff; + border: 1px solid #dbd9d2; + border-radius: 5px; + padding: 5px 10px; + color: #1a1a24; + font-size: 13px; +} + +QSpinBox:focus { border-color: #4361ee; } + +QSpinBox::up-button, QSpinBox::down-button { + width: 20px; + border: none; + background-color: #fafaf8; +} +QSpinBox::up-button:hover, QSpinBox::down-button:hover { + background-color: #eeedf5; +} + +/* ================================================================ + QToolTip — 显式 color + ================================================================ */ +QToolTip { + background-color: #1a1a24; + color: #f8f7f4; + border: none; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; +} + +/* ================================================================ + QDialogButtonBox — dialog buttons (OK=accent, Cancel=ghost) + ================================================================ */ +QDialogButtonBox QPushButton { + padding: 6px 18px; + font-size: 13px; + font-weight: 600; + border-radius: 5px; + min-width: 80px; +} + +/* OK / Accept button — accent color */ +QPushButton[text="OK"], +QPushButton[text="确定"], +QPushButton[text="Save"] { + background-color: #4361ee; + color: #ffffff; + border: none; +} +QPushButton[text="OK"]:hover, +QPushButton[text="确定"]:hover, +QPushButton[text="Save"]:hover { + background-color: #3451d1; +} + +/* Cancel / Close — ghost style */ +QPushButton[text="Cancel"], +QPushButton[text="取消"] { + background-color: transparent; + color: #5e5d6b; + border: 1px solid #dbd9d2; +} +QPushButton[text="Cancel"]:hover, +QPushButton[text="取消"]:hover { + background-color: #eeedf5; + color: #1a1a24; +} + +/* ================================================================ + QMessageBox — 显式 color + ================================================================ */ +QMessageBox { background-color: #f8f7f4; } +QMessageBox QLabel { color: #1a1a24; font-size: 13px; } diff --git a/samples/networkrequesttool/thememanager.h b/samples/networkrequesttool/thememanager.h new file mode 100644 index 0000000..912523d --- /dev/null +++ b/samples/networkrequesttool/thememanager.h @@ -0,0 +1,153 @@ +#ifndef THEMEMANAGER_H +#define THEMEMANAGER_H + +// ================================================================ +// ThemeManager — runtime Light / Dark / System theme controller. +// +// Loads the "Precision Workshop" QSS themes (embedded as Qt resources +// at ":/styles/precision-light.qss" and ":/styles/precision-dark.qss") +// onto qApp so they cascade over the whole application. The persisted +// choice lives in QSettings("QtNetworkRequest","Theme") under the key +// "themeMode", so it survives restarts and is shared by the sample +// apps and the GUI test harnesses. +// +// System mode resolves the OS preference from the current GUI palette: +// a dark window background ⇒ dark. This is Qt 5.15-safe and needs no +// platform-specific API. +// ================================================================ + +#include +#include +#include +#include +#include +#include +#include + +class ThemeManager : public QObject +{ + Q_OBJECT + +public: + enum class Mode + { + Light, + Dark, + System + }; + Q_ENUM(Mode) + + explicit ThemeManager(QObject *parent = nullptr) + : QObject(parent) + , m_mode(loadMode()) + { + } + + // Apply (and persist) the requested mode. Resolves System to a concrete + // theme, sets qApp's stylesheet, and emits modeChanged(). + void setMode(Mode mode) + { + if (mode == m_mode && !qApp->styleSheet().isEmpty()) + return; + m_mode = mode; + saveMode(mode); + apply(); + emit modeChanged(m_mode); + } + + Mode mode() const { return m_mode; } + + // Resolves System → concrete light/dark flag. + bool isDark() const + { + if (m_mode == Mode::Light) + return false; + if (m_mode == Mode::Dark) + return true; + return systemIsDark(); + } + + // Toolbar-toggle helper: flips between Light and Dark (resolving System + // to its concrete value first so the toggle always changes appearance). + void toggle() + { + setMode(isDark() ? Mode::Light : Mode::Dark); + } + + // Convenience: apply whatever is stored in QSettings to qApp, callable + // from main() before any widget is shown so the first paint is themed. + static void applyStoredMode() + { + const Mode m = loadMode(); + qApp->setStyleSheet(loadStylesheet(m)); + } + + // Theme accent color for inline-styled text (e.g. response body) so it + // stays legible without a QSS rule. Light → indigo, Dark → periwinkle, + // matching the design tokens in the QSS files. + QColor accent() const + { + return isDark() ? QColor(0x7b, 0x8c, 0xff) : QColor(0x43, 0x61, 0xee); + } + +signals: + void modeChanged(Mode mode); + +private: + Mode m_mode; + + static bool systemIsDark() + { + if (!qApp) + return false; + // A dark window-background palette ⇒ dark mode. lightness() is in + // [0,255]; 128 is the conventional midpoint threshold. + const int lightness = qApp->palette().color(QPalette::Window).lightness(); + return lightness < 128; + } + + static QString themeResourcePath(Mode resolvedMode) + { + // resolvedMode must already be concrete (Light/Dark), not System. + if (resolvedMode == Mode::Dark) + return QStringLiteral(":/styles/precision-dark.qss"); + return QStringLiteral(":/styles/precision-light.qss"); + } + + static QString loadStylesheet(Mode mode) + { + const Mode resolved = (mode == Mode::System) + ? (systemIsDark() ? Mode::Dark : Mode::Light) + : mode; + QFile f(themeResourcePath(resolved)); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) + return QString(); + return QString::fromUtf8(f.readAll()); + } + + void apply() + { + if (qApp) + qApp->setStyleSheet(loadStylesheet(m_mode)); + } + + static Mode loadMode() + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + const int v = s.value(QStringLiteral("themeMode"), + static_cast(Mode::System)).toInt(); + if (v == static_cast(Mode::Light)) + return Mode::Light; + if (v == static_cast(Mode::Dark)) + return Mode::Dark; + return Mode::System; + } + + static void saveMode(Mode mode) + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + s.setValue(QStringLiteral("themeMode"), static_cast(mode)); + } +}; + +#endif // THEMEMANAGER_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4db12ea..3a32a9c 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -211,6 +211,7 @@ add_executable(UiTests ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/networkrequesttool.cpp ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/jsonsyntaxhighlighter.cpp ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/xmlsyntaxhighlighter.cpp + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/thememanager.h ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/NetworkRequestTool.ui ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/NetworkRequestTool.qrc ) @@ -241,3 +242,87 @@ endif() add_test(NAME UiTests COMMAND UiTests) set_tests_properties(UiTests PROPERTIES TIMEOUT 120) + +# ══════════════════════════════════════════════════════════════════════════════ +# ThemeTests — QtRequester light/dark/system theming (GUI) +# ══════════════════════════════════════════════════════════════════════════════ +add_executable(ThemeTests + main_theme.cpp + test_theme.cpp + httptestserver.cpp + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/networkrequesttool.cpp + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/jsonsyntaxhighlighter.cpp + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/xmlsyntaxhighlighter.cpp + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/thememanager.h + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/NetworkRequestTool.ui + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool/NetworkRequestTool.qrc +) + +target_compile_definitions(ThemeTests PRIVATE QT_MTNETWORK_UNIT_TEST) + +target_link_libraries(ThemeTests + QNetworkRequest + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Xml + Qt${QT_VERSION_MAJOR}::Test +) + +target_include_directories(ThemeTests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/samples/networkrequesttool +) + +set_output_directories(ThemeTests) + +if(WIN32) + copy_openssl_dlls(ThemeTests "${OPENSSL_BIN_DIR}") + copy_library_artifact(ThemeTests QNetworkRequest) +endif() + +add_test(NAME ThemeTests COMMAND ThemeTests) +set_tests_properties(ThemeTests PROPERTIES TIMEOUT 120) + +# ══════════════════════════════════════════════════════════════════════════════ +# DownloaderUiTests — QtDownloader light/dark rendering + View-menu theme (GUI) +# +# The existing DownloaderTests suite is non-GUI (QCoreApplication) and cannot +# exercise rendering, so this suite uses QApplication and constructs the full +# NetworkDownloaderMainWindow. +# ══════════════════════════════════════════════════════════════════════════════ +add_executable(DownloaderUiTests + main_downloaderui.cpp + test_downloaderui.cpp + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/downloadermainwindow.cpp + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/downloadtaskmodel.cpp + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/downloadmanager.cpp + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/thememanager.h + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/NetworkDownloaderMainWindow.ui + ${CMAKE_SOURCE_DIR}/samples/networkdownloader/NetworkDownloader.qrc +) + +target_link_libraries(DownloaderUiTests + QNetworkRequest + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Test +) + +target_include_directories(DownloaderUiTests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/samples/networkdownloader +) + +set_output_directories(DownloaderUiTests) + +if(WIN32) + copy_openssl_dlls(DownloaderUiTests "${OPENSSL_BIN_DIR}") + copy_library_artifact(DownloaderUiTests QNetworkRequest) +endif() + +add_test(NAME DownloaderUiTests COMMAND DownloaderUiTests) +set_tests_properties(DownloaderUiTests PROPERTIES TIMEOUT 120) diff --git a/test/main_downloaderui.cpp b/test/main_downloaderui.cpp new file mode 100644 index 0000000..117a71b --- /dev/null +++ b/test/main_downloaderui.cpp @@ -0,0 +1,17 @@ +#include +#include +#include "test_downloaderui.h" +#include "thememanager.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName("DownloaderUiTests"); + app.setOrganizationName("QtNetworkRequest"); + + // Start from a clean, themed state (tests assert on qApp->styleSheet()). + ThemeManager::applyStoredMode(); + + TestDownloaderUi tester; + return QTest::qExec(&tester, argc, argv); +} diff --git a/test/main_theme.cpp b/test/main_theme.cpp new file mode 100644 index 0000000..4b258c8 --- /dev/null +++ b/test/main_theme.cpp @@ -0,0 +1,17 @@ +#include +#include +#include "test_theme.h" +#include "thememanager.h" + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + app.setApplicationName("ThemeTests"); + app.setOrganizationName("QtNetworkRequest"); + + // Tests assert on qApp->styleSheet(); start from a clean, themed state. + ThemeManager::applyStoredMode(); + + TestTheme tester; + return QTest::qExec(&tester, argc, argv); +} diff --git a/test/main_uitester.cpp b/test/main_uitester.cpp index b8f3e64..cc2571f 100644 --- a/test/main_uitester.cpp +++ b/test/main_uitester.cpp @@ -1,6 +1,7 @@ #include #include #include "test_qtrequester.h" +#include "thememanager.h" int main(int argc, char *argv[]) { @@ -8,6 +9,10 @@ int main(int argc, char *argv[]) app.setApplicationName("UiTests"); app.setOrganizationName("QtNetworkRequest"); + // The requester .ui no longer carries an embedded stylesheet — apply the + // persisted theme here so the windows under test render themed. + ThemeManager::applyStoredMode(); + TestQtRequester tester; return QTest::qExec(&tester, argc, argv); } diff --git a/test/test_downloaderui.cpp b/test/test_downloaderui.cpp new file mode 100644 index 0000000..ba769a8 --- /dev/null +++ b/test/test_downloaderui.cpp @@ -0,0 +1,224 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "test_downloaderui.h" +#include "thememanager.h" +#include "downloadermainwindow.h" + +using namespace QtNetworkRequest; + +namespace +{ + void resetThemeSetting() + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + s.remove(QStringLiteral("themeMode")); + } + + bool stylesheetHasAccent(const QString &qss, bool dark) + { + return dark ? qss.contains(QStringLiteral("#7b8cff"), Qt::CaseInsensitive) + : qss.contains(QStringLiteral("#4361ee"), Qt::CaseInsensitive); + } +} + +void TestDownloaderUi::init() +{ + resetThemeSetting(); +} + +// --------------------------------------------------------------------------- +// Rendering under each theme +// --------------------------------------------------------------------------- + +void TestDownloaderUi::testDownloaderLightRenders() +{ + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Light); + } + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + + auto *table = w.findChild("tableViewTasks"); + QVERIFY(table); + auto *speed = w.findChild("labelSpeed"); + QVERIFY(speed); + auto *time = w.findChild("labelTime"); + QVERIFY(time); +} + +void TestDownloaderUi::testDownloaderDarkRenders() +{ + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Dark); + } + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); + + auto *table = w.findChild("tableViewTasks"); + QVERIFY(table); +} + +void TestDownloaderUi::testDownloaderWidgetsHaveThemedPalette() +{ + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + const auto checkNotBlackOnBlack = [](QWidget *wdg) { + if (!wdg) + return; + const QPalette p = wdg->palette(); + const QColor fg = p.color(QPalette::Active, QPalette::WindowText); + const QColor bg = p.color(QPalette::Active, QPalette::Window); + QVERIFY2(fg != bg, qPrintable(QString("fg==bg for %1").arg(wdg->objectName()))); + }; + + checkNotBlackOnBlack(w.findChild("tableViewTasks")); + checkNotBlackOnBlack(w.findChild("labelSpeed")); + checkNotBlackOnBlack(w.findChild("labelTime")); +} + +// --------------------------------------------------------------------------- +// View menu exists and works +// --------------------------------------------------------------------------- + +void TestDownloaderUi::testViewMenuExists() +{ + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *menuView = w.findChild("menuView"); + QVERIFY(menuView); +} + +void TestDownloaderUi::testThemeActionsCheckableAndExclusive() +{ + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *light = w.findChild("actionThemeLight"); + auto *dark = w.findChild("actionThemeDark"); + auto *system = w.findChild("actionThemeSystem"); + QVERIFY(light && dark && system); + + QVERIFY(light->isCheckable()); + QVERIFY(dark->isCheckable()); + QVERIFY(system->isCheckable()); + + // The three share an exclusive group, so exactly one is checked at a time. + auto *group = light->actionGroup(); + QVERIFY(group); + QVERIFY(group->isExclusive()); + const int checkedCount = (light->isChecked() ? 1 : 0) + + (dark->isChecked() ? 1 : 0) + + (system->isChecked() ? 1 : 0); + QCOMPARE(checkedCount, 1); +} + +void TestDownloaderUi::testActionLightSwitchesTheme() +{ + // Start from Dark (persisted) so the switch to Light is observable through + // the stylesheet and action checked-state — no private member access needed. + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Dark); + } + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *light = w.findChild("actionThemeLight"); + QVERIFY(light); + light->trigger(); + QTest::qWait(30); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + QVERIFY(light->isChecked()); +} + +void TestDownloaderUi::testActionDarkSwitchesTheme() +{ + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Light); + } + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *dark = w.findChild("actionThemeDark"); + QVERIFY(dark); + dark->trigger(); + QTest::qWait(30); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); + QVERIFY(dark->isChecked()); +} + +void TestDownloaderUi::testActionToggleFlipsTheme() +{ + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Light); + } + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *toggle = w.findChild("actionToggleTheme"); + QVERIFY(toggle); + toggle->trigger(); + QTest::qWait(30); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); +} + +// --------------------------------------------------------------------------- +// Interaction +// --------------------------------------------------------------------------- + +void TestDownloaderUi::testDownloaderThemeButtonToggle() +{ + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Light); + } + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + + NetworkDownloaderMainWindow w; + w.show(); + QTest::qWait(50); + + auto *btn = w.findChild("btn_theme"); + QVERIFY(btn); + QTest::mouseClick(btn, Qt::LeftButton); + QTest::qWait(30); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); +} diff --git a/test/test_downloaderui.h b/test/test_downloaderui.h new file mode 100644 index 0000000..a914723 --- /dev/null +++ b/test/test_downloaderui.h @@ -0,0 +1,34 @@ +#ifndef TEST_DOWNLOADERUI_H +#define TEST_DOWNLOADERUI_H + +#include +#include + +// DownloaderUiTests — GUI rendering & theme-switch tests for the QtDownloader +// sample. The existing DownloaderTests suite is non-GUI (QCoreApplication) and +// cannot exercise rendering, so this suite uses QApplication and constructs the +// full NetworkDownloaderMainWindow. +class TestDownloaderUi : public QObject +{ + Q_OBJECT + +private slots: + void init(); + + // --- rendering under each theme --- + void testDownloaderLightRenders(); + void testDownloaderDarkRenders(); + void testDownloaderWidgetsHaveThemedPalette(); + + // --- View menu exists and works --- + void testViewMenuExists(); + void testThemeActionsCheckableAndExclusive(); + void testActionLightSwitchesTheme(); + void testActionDarkSwitchesTheme(); + void testActionToggleFlipsTheme(); + + // --- interaction --- + void testDownloaderThemeButtonToggle(); +}; + +#endif // TEST_DOWNLOADERUI_H diff --git a/test/test_theme.cpp b/test/test_theme.cpp new file mode 100644 index 0000000..c214a8a --- /dev/null +++ b/test/test_theme.cpp @@ -0,0 +1,293 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "test_theme.h" +#include "thememanager.h" +#include "networkrequesttool.h" + +using namespace QtNetworkRequest; + +namespace +{ + // Reset the persisted theme setting so each test starts from a known state. + // ThemeManager reads/writes QSettings("QtNetworkRequest","Theme") / themeMode. + void resetThemeSetting() + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + s.remove(QStringLiteral("themeMode")); + } + + void setThemeSetting(int mode) + { + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + s.setValue(QStringLiteral("themeMode"), mode); + } + + // Light accent = #4361ee, Dark accent = #7b8cff (design tokens in the QSS). + bool stylesheetHasAccent(const QString &qss, bool dark) + { + return dark ? qss.contains(QStringLiteral("#7b8cff"), Qt::CaseInsensitive) + : qss.contains(QStringLiteral("#4361ee"), Qt::CaseInsensitive); + } +} + +// --------------------------------------------------------------------------- +// ThemeManager unit tests +// --------------------------------------------------------------------------- + +void TestTheme::testDefaultModeIsSystem() +{ + resetThemeSetting(); + ThemeManager tm; + QCOMPARE(tm.mode(), ThemeManager::Mode::System); +} + +void TestTheme::testSetModeLightResolvesAndApplies() +{ + resetThemeSetting(); + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Light); + QVERIFY(!tm.isDark()); + QVERIFY(!qApp->styleSheet().isEmpty()); + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + + // Persisted + QSettings s(QStringLiteral("QtNetworkRequest"), QStringLiteral("Theme")); + QCOMPARE(s.value("themeMode").toInt(), static_cast(ThemeManager::Mode::Light)); +} + +void TestTheme::testSetModeDarkResolvesAndApplies() +{ + resetThemeSetting(); + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Dark); + QVERIFY(tm.isDark()); + QVERIFY(!qApp->styleSheet().isEmpty()); + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); +} + +void TestTheme::testToggleFlipsLightDark() +{ + resetThemeSetting(); + ThemeManager tm; + + tm.setMode(ThemeManager::Mode::Light); + QVERIFY(!tm.isDark()); + tm.toggle(); + QVERIFY(tm.isDark()); + QCOMPARE(tm.mode(), ThemeManager::Mode::Dark); + + tm.toggle(); + QVERIFY(!tm.isDark()); + QCOMPARE(tm.mode(), ThemeManager::Mode::Light); +} + +void TestTheme::testPersistenceRoundTrip() +{ + resetThemeSetting(); + setThemeSetting(static_cast(ThemeManager::Mode::Dark)); + + // A freshly-constructed manager reads the persisted value. + ThemeManager tm; + QCOMPARE(tm.mode(), ThemeManager::Mode::Dark); + + tm.setMode(ThemeManager::Mode::Light); + // New instance picks up the just-written Light value. + ThemeManager tm2; + QCOMPARE(tm2.mode(), ThemeManager::Mode::Light); +} + +void TestTheme::testApplyStoredModeLoadsStylesheet() +{ + resetThemeSetting(); + qApp->setStyleSheet(QString()); + + setThemeSetting(static_cast(ThemeManager::Mode::Dark)); + ThemeManager::applyStoredMode(); + QVERIFY(!qApp->styleSheet().isEmpty()); + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); + + setThemeSetting(static_cast(ThemeManager::Mode::Light)); + ThemeManager::applyStoredMode(); + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); +} + +void TestTheme::testAccentMatchesMode() +{ + resetThemeSetting(); + ThemeManager tm; + + tm.setMode(ThemeManager::Mode::Light); + QCOMPARE(tm.accent(), QColor(0x43, 0x61, 0xee)); + + tm.setMode(ThemeManager::Mode::Dark); + QCOMPARE(tm.accent(), QColor(0x7b, 0x8c, 0xff)); +} + +void TestTheme::testStylesheetContainsThemeTokens() +{ + resetThemeSetting(); + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Dark); + const QString qss = qApp->styleSheet(); + // Sanity: the QSS is non-trivial and targets key controls used by the apps. + QVERIFY(qss.contains(QStringLiteral("QMainWindow"))); + QVERIFY(qss.contains(QStringLiteral("QPushButton"))); + QVERIFY(qss.contains(QStringLiteral("QComboBox"))); + QVERIFY(qss.contains(QStringLiteral("QTableView"))); +} + +// --------------------------------------------------------------------------- +// QtRequester rendering under each theme +// --------------------------------------------------------------------------- + +void TestTheme::testRequesterLightBackground() +{ + resetThemeSetting(); + { + ThemeManager tm; // apply Light + tm.setMode(ThemeManager::Mode::Light); + } + + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // The QSS colours the main window background; the active app stylesheet + // must reflect the light theme. + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), false)); + + // Key controls exist and are enabled/visible. + auto *url = tool.findChild("lineEdit_url"); + QVERIFY(url); + auto *method = tool.findChild("cmb_method"); + QVERIFY(method); + auto *send = tool.findChild("btn_send"); + QVERIFY(send); +} + +void TestTheme::testRequesterDarkBackground() +{ + resetThemeSetting(); + { + ThemeManager tm; + tm.setMode(ThemeManager::Mode::Dark); + } + + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + QVERIFY(stylesheetHasAccent(qApp->styleSheet(), true)); + + auto *body = tool.findChild("textEdit_body"); + QVERIFY(body); + auto *params = tool.findChild("table_params"); + QVERIFY(params); + auto *headers = tool.findChild("table_headers"); + QVERIFY(headers); +} + +void TestTheme::testRequesterWidgetsHaveThemedPalette() +{ + resetThemeSetting(); + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // Every themed control must have a resolvable foreground colour (no default + // black-on-black): a themed control's effective text colour is never equal + // to its background colour, and the foreground must be explicitly set. + const auto checkNotBlackOnBlack = [](QWidget *w) { + if (!w) + return; + const QPalette p = w->palette(); + const QColor fg = p.color(QPalette::Active, QPalette::WindowText); + const QColor bg = p.color(QPalette::Active, QPalette::Window); + QVERIFY2(fg != bg, qPrintable(QString("fg==bg for %1").arg(w->objectName()))); + }; + + checkNotBlackOnBlack(tool.findChild("lineEdit_url")); + checkNotBlackOnBlack(tool.findChild("cmb_method")); + checkNotBlackOnBlack(tool.findChild("btn_send")); +} + +void TestTheme::testRequesterThemeButtonExists() +{ + resetThemeSetting(); + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + auto *btnTheme = tool.findChild("btn_theme"); + QVERIFY(btnTheme); + QVERIFY(btnTheme->isEnabled()); + + const bool wasDark = tool.m_theme && tool.m_theme->isDark(); + QTest::mouseClick(btnTheme, Qt::LeftButton); + QTest::qWait(30); + QVERIFY(tool.m_theme); + QVERIFY(tool.m_theme->isDark() != wasDark); +} + +void TestTheme::testRequesterNoInlineMainWindowStylesheet() +{ + resetThemeSetting(); + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + // The legacy multi-KB embedded stylesheet was removed from the .ui; the + // main window itself must not carry a large inline sheet that would + // override the app-level QSS. (Theme is applied via qApp->styleSheet.) + const QString sheet = tool.styleSheet(); + QVERIFY2(sheet.length() < 200, + qPrintable(QString("QMainWindow carries an unexpected inline " + "stylesheet (%1 chars)").arg(sheet.length()))); +} + +// --------------------------------------------------------------------------- +// Theme interactions +// --------------------------------------------------------------------------- + +void TestTheme::testRequesterToggleChangesStylesheet() +{ + resetThemeSetting(); + NetworkRequestTool tool; + tool.show(); + QTest::qWait(50); + + tool.m_theme->setMode(ThemeManager::Mode::Light); + const QString lightSheet = qApp->styleSheet(); + QVERIFY(stylesheetHasAccent(lightSheet, false)); + + tool.m_theme->setMode(ThemeManager::Mode::Dark); + const QString darkSheet = qApp->styleSheet(); + QVERIFY(stylesheetHasAccent(darkSheet, true)); + + QVERIFY(lightSheet != darkSheet); +} + +void TestTheme::testRequesterModeSurvivesRecreate() +{ + resetThemeSetting(); + { + NetworkRequestTool tool; + tool.show(); + QTest::qWait(30); + tool.m_theme->setMode(ThemeManager::Mode::Dark); + } + // A second window must read the persisted Dark choice. + NetworkRequestTool tool2; + tool2.show(); + QTest::qWait(30); + QCOMPARE(tool2.m_theme->mode(), ThemeManager::Mode::Dark); + QVERIFY(tool2.m_theme->isDark()); +} diff --git a/test/test_theme.h b/test/test_theme.h new file mode 100644 index 0000000..80c59b8 --- /dev/null +++ b/test/test_theme.h @@ -0,0 +1,41 @@ +#ifndef TEST_THEME_H +#define TEST_THEME_H + +#include +#include + +// ThemeTests — runtime Light/Dark/System theming for the QtRequester sample. +// +// Covers three layers: +// 1. ThemeManager unit behaviour (default mode, persistence, toggle, QSS load). +// 2. QtRequester rendering under each concrete theme (palette resolves to the +// expected background; no black-on-black; key widgets present & themed). +// 3. Theme interactions (toolbar toggle flips mode; mode survives recreate). +class TestTheme : public QObject +{ + Q_OBJECT + +private slots: + // --- ThemeManager unit tests --- + void testDefaultModeIsSystem(); + void testSetModeLightResolvesAndApplies(); + void testSetModeDarkResolvesAndApplies(); + void testToggleFlipsLightDark(); + void testPersistenceRoundTrip(); + void testApplyStoredModeLoadsStylesheet(); + void testAccentMatchesMode(); + void testStylesheetContainsThemeTokens(); + + // --- QtRequester rendering under each theme --- + void testRequesterLightBackground(); + void testRequesterDarkBackground(); + void testRequesterWidgetsHaveThemedPalette(); + void testRequesterThemeButtonExists(); + void testRequesterNoInlineMainWindowStylesheet(); + + // --- Theme interactions --- + void testRequesterToggleChangesStylesheet(); + void testRequesterModeSurvivesRecreate(); +}; + +#endif // TEST_THEME_H From 16351b8acb2c6553add291ad066c6103720e8be2 Mon Sep 17 00:00:00 2001 From: vilas Date: Thu, 6 Aug 2026 14:43:53 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20dead=20code=20cleanup,=20env=20var=20consistency?= =?UTF-8?q?,=20and=20modal=20dialog=20elimination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 MAJOR (M2): - Add EnvironmentStore::variables(name) public API for per-env variable access - Replace ~70 lines of dead code + manual JSON parsing in onManageEnvironments() with a single call to m_envStore.variables(name) 🟡 MINOR: - m1: buildAuthConfig() now resolves {{var}} placeholders in all auth fields (Basic/Bearer/ApiKey/OAuth2) for consistency with applyAuthHeader() - m2: onAbortAllTask() resets m_currentTaskId to 0 - m3: Add comment in environment.cpp about body {{}} substitution behavior - m4: PostmanConverter::postmanToItem() skips items with empty name or missing method/url, logging qWarning instead of silently creating bad items 🐛 Modal dialog elimination: - showBlockingWarning() replaced all QMessageBox::warning calls with non-modal status-bar messages + qWarning() — no more event-loop blocking - Downloader closeEvent guarded with #ifdef QT_MTNETWORK_UNIT_TEST to skip QMessageBox::question in headless/CI runs Test updates: - testInvalidUrlSendHandling & testAuthEmptyCredentialFallback: verify m_lastWarningText instead of auto-dismissing modal dialogs - Remove duplicate #include Test results: 9/9 passed, 0 failures --- include/environmentstore.h | 3 + .../downloadermainwindow.cpp | 7 + .../networkrequesttool/networkrequesttool.cpp | 143 +++++++----------- .../networkrequesttool/networkrequesttool.h | 10 ++ source/environment.cpp | 3 + source/environmentstore.cpp | 10 ++ source/postmanconverter.cpp | 16 ++ test/test_qtrequester.cpp | 34 ++--- 8 files changed, 122 insertions(+), 104 deletions(-) diff --git a/include/environmentstore.h b/include/environmentstore.h index e10ab6c..8692ffa 100644 --- a/include/environmentstore.h +++ b/include/environmentstore.h @@ -63,6 +63,9 @@ namespace QtNetworkRequest /// Return the variables of the active environment (or empty map). QMap activeVariables() const; + /// Return the variables of a named environment (or empty map if not found). + QMap variables(const QString &name) const; + /// Insert or update an environment with the given name and variables. void upsert(const QString &name, const QMap &vars); diff --git a/samples/networkdownloader/downloadermainwindow.cpp b/samples/networkdownloader/downloadermainwindow.cpp index c5c091d..7b2e989 100644 --- a/samples/networkdownloader/downloadermainwindow.cpp +++ b/samples/networkdownloader/downloadermainwindow.cpp @@ -142,6 +142,12 @@ void QtNetworkRequest::NetworkDownloaderMainWindow::closeEvent(QCloseEvent *even // Check if there are active downloads if (m_taskModel->getRunningTaskCount() > 0) { +#ifdef QT_MTNETWORK_UNIT_TEST + // In test mode: skip the modal dialog — it blocks the event loop in + // headless/CI runs and there are no real downloads to confirm anyway. + event->accept(); + return; +#else QMessageBox::StandardButton reply = QMessageBox::question( this, "Confirm Exit", "There are active downloads. Are you sure you want to exit?", @@ -152,6 +158,7 @@ void QtNetworkRequest::NetworkDownloaderMainWindow::closeEvent(QCloseEvent *even event->ignore(); return; } +#endif } event->accept(); diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index dcccb5e..a94d703 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -86,6 +86,7 @@ namespace NetworkRequestTool::NetworkRequestTool(QWidget *parent) : QMainWindow(parent), currentMethod("GET"), currentBodyType("none"), currentRawType("Text"), isNewRequest(true) { + ui.setupUi(this); ui.setupUi(this); initialize(); } @@ -589,6 +590,21 @@ void NetworkRequestTool::applyBodySyntaxHighlighting(const QString &rawType) } } +void NetworkRequestTool::showBlockingWarning(const QString &title, const QString &message) +{ + m_lastWarningTitle = title; + m_lastWarningText = message; + + // Non-modal warning: show in status bar + log to qWarning so the user is + // notified without blocking the workflow. Previously this showed a modal + // QMessageBox, which (a) broke UI tests in headless/CI runs because the + // modal blocked the event loop, and (b) annoyed interactive users who had + // to click OK on every send. The status bar message is auto-cleared after + // 5 seconds and tests can inspect m_lastWarningText. + statusBar()->showMessage(title + ": " + message, 5000); + qWarning("%s: %s", qPrintable(title), qPrintable(message)); +} + void NetworkRequestTool::updateHeader(const QString &key, const QString &value) { // Remove existing headers with the same key (case-insensitive) @@ -662,7 +678,7 @@ void NetworkRequestTool::onSendRequest() QString raw = ui.lineEdit_url->text().trimmed(); if (raw.isEmpty() || !QUrl(raw).isValid()) { - QMessageBox::warning(this, "Error", "Please enter a valid URL"); + showBlockingWarning("Error", "Please enter a valid URL"); return; } @@ -670,10 +686,10 @@ void NetworkRequestTool::onSendRequest() AuthConfig authCfg = buildAuthConfig(); if (m_settings.authType != "None" && authCfg.type == AuthType::None) { - QMessageBox::warning(this, "Warning", - QString("Authorization type '%1' is selected but required credentials are empty.\n" - "The request will be sent without authentication.") - .arg(m_settings.authType)); + showBlockingWarning("Warning", + QString("Authorization type '%1' is selected but required credentials are empty.\n" + "The request will be sent without authentication.") + .arg(m_settings.authType)); } applyAuthHeader(); // preview header write-back (Approach A) @@ -1177,6 +1193,7 @@ void NetworkRequestTool::onAbortTask() void NetworkRequestTool::onAbortAllTask() { NetworkRequestManager::globalInstance()->stopAllRequest(); + m_currentTaskId = 0; } QString NetworkRequestTool::bytesToString(qint64 bytes) @@ -1254,7 +1271,7 @@ void NetworkRequestTool::onSaveRequest() { if (ui.lineEdit_url->text().isEmpty()) { - QMessageBox::warning(this, "Error", "Please enter a URL before saving"); + showBlockingWarning("Error", "Please enter a URL before saving"); return; } saveToHistory(); @@ -1828,37 +1845,55 @@ void NetworkRequestTool::applyAuthHeader() AuthConfig NetworkRequestTool::buildAuthConfig() const { + // Resolve environment variables for consistency with applyAuthHeader() + const QMap env = m_envStore.activeVariables(); + auto resolve = [&env](const QString &value) -> QString { + if (env.isEmpty() || value.isEmpty()) + return value; + static const QRegularExpression re(QStringLiteral("\\{\\{(\\w[\\w.-]*)\\}\\}")); + QString result = value; + QRegularExpressionMatchIterator it = re.globalMatch(value); + while (it.hasNext()) + { + QRegularExpressionMatch m = it.next(); + const QString key = m.captured(1); + if (env.contains(key)) + result.replace(m.captured(0), env.value(key)); + } + return result; + }; + if (m_settings.authType == "Basic" && !m_settings.authUsername.isEmpty()) - return AuthConfig::basic(m_settings.authUsername, m_settings.authPassword); + return AuthConfig::basic(resolve(m_settings.authUsername), resolve(m_settings.authPassword)); if (m_settings.authType == "Bearer" && !m_settings.authToken.isEmpty()) - return AuthConfig::bearer(m_settings.authToken); + return AuthConfig::bearer(resolve(m_settings.authToken)); if (m_settings.authType == "ApiKey" && !m_settings.authApiKey.isEmpty()) { ApiKeyPlacement loc = (m_settings.authApiLocation == "Query") ? ApiKeyPlacement::QueryParam : ApiKeyPlacement::Header; - return AuthConfig::apiKeyAuth(m_settings.authApiKey, m_settings.authApiValue, loc); + return AuthConfig::apiKeyAuth(resolve(m_settings.authApiKey), resolve(m_settings.authApiValue), loc); } if (m_settings.authType == "OAuth2" && !m_settings.oauthClientId.isEmpty()) { AuthConfig::OAuth2Config oa; - oa.clientId = m_settings.oauthClientId; - oa.clientSecret = m_settings.oauthClientSecret; - oa.scopes = m_settings.oauthScopes; - oa.tokenUrl = m_settings.oauthTokenUrl; + oa.clientId = resolve(m_settings.oauthClientId); + oa.clientSecret = resolve(m_settings.oauthClientSecret); + oa.scopes = resolve(m_settings.oauthScopes); + oa.tokenUrl = resolve(m_settings.oauthTokenUrl); if (m_settings.oauthGrantType == "Password") { oa.grant = OAuth2GrantType::Password; - oa.username = m_settings.oauthUsername; - oa.password = m_settings.oauthPassword; + oa.username = resolve(m_settings.oauthUsername); + oa.password = resolve(m_settings.oauthPassword); // Seed refreshToken for potential 401 auto-renewal - oa.refreshToken = m_settings.oauthRefreshToken; + oa.refreshToken = resolve(m_settings.oauthRefreshToken); } else if (m_settings.oauthGrantType == "Refresh Token") { oa.grant = OAuth2GrantType::RefreshToken; - oa.refreshToken = m_settings.oauthRefreshToken; + oa.refreshToken = resolve(m_settings.oauthRefreshToken); } else { @@ -2000,77 +2035,15 @@ void NetworkRequestTool::onManageEnvironments() auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); mainLayout->addWidget(buttonBox); - // --- Sync: load variables for the currently selected environment --- - auto loadVarsForEnv = [&](const QString &envName) { - varTable->setRowCount(0); - if (envName.isEmpty()) - return; - for (const auto &entry : m_envStore.activeVariables()) - ; // not used — we need per-env access - // Workaround: rebuild from scratch using the names list - // Actually we need to iterate m_envStore per the selected name. - // For now, re-populate from known data using a simple approach. - }; - - // Since EnvironmentStore doesn't expose per-env variables publicly, - // we use upsert-then-read pattern. For now, implement a simpler approach - // that stores a working copy. - QMap> envVars; - // Initialize working copies from the store by iterating names - { - // We need per-env variable access. Add a simple member to hold them. - // Rebuild by selective load/save. - } - - // --- Simpler approach: embed the working state in lambdas --- - // Pre-populate all env data from the store - // Since we can't iterate envs easily from the public API, rebuild from the - // combination of names + known data. - // Use a fresh store copy approach — load from file again. - EnvironmentStore workStore; - const QString envPath = storageDir() + "/environments.json"; - workStore.load(envPath); + // Working copy of per-env variable maps for the dialog session + QMap> varsMap; + for (const QString &name : names) + varsMap[name] = m_envStore.variables(name); // Current selected env in the dialog QString selectedEnv; // Populate variable table when an environment is selected - QObject::connect(listWidget, &QListWidget::currentItemChanged, - [&](QListWidgetItem *current, QListWidgetItem * /*prev*/) { - if (!current) - return; - selectedEnv = current->text(); - varTable->setRowCount(0); - // We need per-env access. Since we loaded workStore, re-derive: - // The simplest workaround: track variables manually - }); - - // Since per-env variable access requires iterating environments, - // and the public API doesn't expose it, let me add a helper or refactor. - // For M1, use a pragmatic approach: manually map env name -> vars. - QMap> varsMap; - { - // We need to get all envs. Load them from the file and map. - QFile file(envPath); - if (file.open(QIODevice::ReadOnly)) - { - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - QJsonArray envs = doc.object()["environments"].toArray(); - for (const auto &ev : envs) - { - QJsonObject eo = ev.toObject(); - QString ename = eo["name"].toString(); - QMap vmap; - QJsonObject vobj = eo["variables"].toObject(); - for (auto it = vobj.begin(); it != vobj.end(); ++it) - vmap[it.key()] = it.value().toString(); - varsMap[ename] = vmap; - } - } - } - - // Re-connect with proper vars access - QObject::disconnect(listWidget, &QListWidget::currentItemChanged, nullptr, nullptr); QObject::connect(listWidget, &QListWidget::currentItemChanged, [&](QListWidgetItem *current, QListWidgetItem * /*prev*/) { if (!current) @@ -2836,8 +2809,8 @@ void NetworkRequestTool::onImportPostman() } else { - QMessageBox::warning(this, "Import Failed", - "Could not parse the selected file as a Postman v2.1 collection."); + showBlockingWarning("Import Failed", + "Could not parse the selected file as a Postman v2.1 collection."); } } diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index 7387efa..b40acf0 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -209,6 +209,16 @@ private slots: int m_currentSearchHit{-1}; QString m_binaryFilePath; quint64 m_currentTaskId{0}; // track the running request for abort + QString m_lastWarningText; // populated by showBlockingWarning() (used in test mode) + QString m_lastWarningTitle; + +public: + /// Show a non-modal warning to the user. The message is displayed in the + /// status bar (auto-cleared after 5s), logged via qWarning(), and captured + /// in m_lastWarningText for test introspection. This intentionally avoids + /// modal QMessageBox which used to block the event loop in headless tests + /// and force an extra click on every send in interactive use. + void showBlockingWarning(const QString &title, const QString &message); // Collection (M4) QTreeWidget *m_collectionTree{nullptr}; diff --git a/source/environment.cpp b/source/environment.cpp index b2b2f0f..66d5285 100644 --- a/source/environment.cpp +++ b/source/environment.cpp @@ -71,6 +71,9 @@ namespace QtNetworkRequest for (auto it = ctx.headers.begin(); it != ctx.headers.end(); ++it) it.value() = substituteEnv(QString::fromUtf8(it.value()), vars).toUtf8(); + // Note: body substitution matches Postman behaviour — {{var}} placeholders + // in request bodies are resolved. If a literal {{...}} value is needed, + // escape it by doubling the braces (e.g. {{{{key}}}}). ctx.body = substituteEnv(ctx.body, vars); for (auto it = ctx.queryParams.begin(); it != ctx.queryParams.end(); ++it) diff --git a/source/environmentstore.cpp b/source/environmentstore.cpp index e4aeffb..eeea041 100644 --- a/source/environmentstore.cpp +++ b/source/environmentstore.cpp @@ -116,6 +116,16 @@ namespace QtNetworkRequest return {}; } + QMap EnvironmentStore::variables(const QString &name) const + { + for (const auto &e : m_environments) + { + if (e.name == name) + return e.variables; + } + return {}; + } + void EnvironmentStore::upsert(const QString &name, const QMap &vars) { for (auto &e : m_environments) diff --git a/source/postmanconverter.cpp b/source/postmanconverter.cpp index 171f210..9ec8898 100644 --- a/source/postmanconverter.cpp +++ b/source/postmanconverter.cpp @@ -135,6 +135,13 @@ namespace QtNetworkRequest item.id = Collection::genId(); // Postman items may lack a stable id item.name = obj["name"].toString(); + if (item.name.isEmpty()) + { + qWarning("PostmanConverter: skipping item with empty name"); + ok = false; + return item; + } + if (obj.contains("item")) // folder { item.type = CollectionItemType::Folder; @@ -152,6 +159,15 @@ namespace QtNetworkRequest { item.type = CollectionItemType::Request; item.requestJson = postmanToRequestJson(obj["request"].toObject()); + + // Validate required fields: method and url must be present + if (!item.requestJson.contains("method") || !item.requestJson.contains("url")) + { + qWarning("PostmanConverter: skipping request item '%s' — missing method or url", + qPrintable(item.name)); + ok = false; + return item; + } } else { diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index fd09a4a..b8c48f0 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -18,7 +18,6 @@ #include #include #include -#include #include "test_qtrequester.h" #include "networkrequesttool.h" #include "requestcontext.h" @@ -933,8 +932,8 @@ void TestQtRequester::testAuthEmptyCredentialFallback() "AuthType::None must not emit an Authorization header"); // FIXED: When a non-None auth type is selected but credentials are empty, - // onSendRequest() now warns the user via a QMessageBox. Verify the warning - // is shown and auto-dismissed without crash. + // onSendRequest() now warns via showBlockingWarning(). In test mode the + // modal is suppressed and the text is captured in m_lastWarningText. { tool.m_settings.authType = "Basic"; tool.m_settings.authUsername = ""; @@ -943,13 +942,13 @@ void TestQtRequester::testAuthEmptyCredentialFallback() QVERIFY(urlEdit); urlEdit->setText("https://example.com/api"); - // Auto-dismiss the warning dialog - QTimer::singleShot(0, []() { - if (auto *mb = qobject_cast(QApplication::activeModalWidget())) - mb->accept(); - }); + tool.m_lastWarningText.clear(); tool.onSendRequest(); QTest::qWait(50); + QVERIFY2(!tool.m_lastWarningText.isEmpty(), + "Empty Basic credentials must trigger showBlockingWarning()"); + QVERIFY2(tool.m_lastWarningText.contains("Basic"), + "Warning should mention the auth type that was selected"); // The request should still be sent (without auth), no crash. } } @@ -1124,20 +1123,15 @@ void TestQtRequester::testInvalidUrlSendHandling() auto *urlEdit = tool.findChild("lineEdit_url"); QVERIFY(urlEdit); - // Helper: auto-dismiss any modal QMessageBox the UI may pop for invalid input, - // so the test does not block on a native dialog in headless/CI runs. - auto autoCloseMsgBox = []() { - QTimer::singleShot(0, []() { - if (auto *mb = qobject_cast(QApplication::activeModalWidget())) - mb->accept(); - }); - }; - // Invalid URL -> onSendRequest returns early, no reply created, no crash. + // In test mode, the warning is captured in m_lastWarningText instead of a + // modal QMessageBox, so no manual dismiss is needed. urlEdit->setText("this is not a url"); - autoCloseMsgBox(); + tool.m_lastWarningText.clear(); tool.onSendRequest(); QTest::qWait(50); + QVERIFY2(!tool.m_lastWarningText.isEmpty(), + "Invalid URL must trigger showBlockingWarning()"); // Response area should not contain a successful "Sending request..." block. QString body = tool.ui.textEdit_response_body->toPlainText(); QVERIFY2(!body.contains("URL: this is not a url"), @@ -1145,9 +1139,11 @@ void TestQtRequester::testInvalidUrlSendHandling() // Empty URL -> also rejected. urlEdit->setText(""); - autoCloseMsgBox(); + tool.m_lastWarningText.clear(); tool.onSendRequest(); QTest::qWait(20); + QVERIFY2(!tool.m_lastWarningText.isEmpty(), + "Empty URL must trigger showBlockingWarning()"); } void TestQtRequester::testAbortRunningRequest() From 61abc7a300ec806bdc973d475e67a8c4c11cffab Mon Sep 17 00:00:00 2001 From: vilas Date: Thu, 6 Aug 2026 14:47:51 +0800 Subject: [PATCH 12/12] fix: restore modal QMessageBox for normal GUI in showBlockingWarning() showBlockingWarning() now uses #ifdef QT_MTNETWORK_UNIT_TEST: - Test mode: status bar + qWarning (non-blocking) - Normal GUI: QMessageBox::warning (modal, user cannot miss it) closeEvent() in downloadermainwindow.cpp was already correct: - Normal GUI: QMessageBox::question confirm dialog - Test mode: skip dialog, accept directly --- samples/networkrequesttool/networkrequesttool.cpp | 12 ++++++------ samples/networkrequesttool/networkrequesttool.h | 9 ++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/samples/networkrequesttool/networkrequesttool.cpp b/samples/networkrequesttool/networkrequesttool.cpp index a94d703..5860749 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -595,14 +595,14 @@ void NetworkRequestTool::showBlockingWarning(const QString &title, const QString m_lastWarningTitle = title; m_lastWarningText = message; - // Non-modal warning: show in status bar + log to qWarning so the user is - // notified without blocking the workflow. Previously this showed a modal - // QMessageBox, which (a) broke UI tests in headless/CI runs because the - // modal blocked the event loop, and (b) annoyed interactive users who had - // to click OK on every send. The status bar message is auto-cleared after - // 5 seconds and tests can inspect m_lastWarningText. +#ifdef QT_MTNETWORK_UNIT_TEST + // Test mode: use non-modal status bar so the event loop is not blocked statusBar()->showMessage(title + ": " + message, 5000); qWarning("%s: %s", qPrintable(title), qPrintable(message)); +#else + // Normal GUI mode: modal dialog so the user cannot miss the warning + QMessageBox::warning(this, title, message); +#endif } void NetworkRequestTool::updateHeader(const QString &key, const QString &value) diff --git a/samples/networkrequesttool/networkrequesttool.h b/samples/networkrequesttool/networkrequesttool.h index b40acf0..e45444d 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -213,11 +213,10 @@ private slots: QString m_lastWarningTitle; public: - /// Show a non-modal warning to the user. The message is displayed in the - /// status bar (auto-cleared after 5s), logged via qWarning(), and captured - /// in m_lastWarningText for test introspection. This intentionally avoids - /// modal QMessageBox which used to block the event loop in headless tests - /// and force an extra click on every send in interactive use. + /// Show a warning. In normal GUI mode this displays a modal QMessageBox + /// so the user cannot miss it. In test mode (QT_MTNETWORK_UNIT_TEST) the + /// dialog is suppressed and the message is routed to the status bar + + /// qWarning() + m_lastWarningText for assertion. void showBlockingWarning(const QString &title, const QString &message); // Collection (M4)