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/authconfig.h b/include/authconfig.h new file mode 100644 index 0000000..2eb7a49 --- /dev/null +++ b/include/authconfig.h @@ -0,0 +1,197 @@ +/* +@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 + OAuth2 = 4, // OAuth 2.0 (client-credentials / password / refresh-token) + }; + + /// 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 + }; + + /// 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 + { + 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 }; + + /// 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. + /// @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; + } + + /// 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 + { + switch (type) + { + 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; + } + } + + /// 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(); + 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 {}; + } + } + + /// 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/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/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/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/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 7cbc458..b0cd7b5 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,21 @@ 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; + + // 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; // 行为配置 @@ -162,6 +192,61 @@ 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; + } + /// 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) { @@ -169,6 +254,55 @@ 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; + } + RequestContextBuilder &authOAuth2(const AuthConfig::OAuth2Config &cfg) + { + m_context->authConfig = AuthConfig::oauth2(cfg); + 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; + } + + /// 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/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..c4681bd 100644 --- a/samples/networkrequesttool/networkrequesttool.cpp +++ b/samples/networkrequesttool/networkrequesttool.cpp @@ -6,13 +6,21 @@ #include #include #include "networkrequesttool.h" +#include "postmanconverter.h" #include #include #include +#include +#include #include #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -20,12 +28,16 @@ #include #include #include +#include +#include +#include #include #include #include #include #include #include +#include #include #include #include "networkrequestmanager.h" @@ -54,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; } @@ -75,16 +87,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() @@ -99,6 +101,8 @@ void NetworkRequestTool::initialize() initializeUI(); initializeConnections(); setupDefaultValues(); + loadEnvironments(); + loadCollection(); } void NetworkRequestTool::unInitialize() @@ -120,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); @@ -162,6 +237,46 @@ 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); + } + + // --- Response toolbar (injected into response body tab) --- + buildResponseToolbar(); + + // --- 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() @@ -175,6 +290,18 @@ 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); + + // 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); @@ -188,7 +315,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.cmb_body_type, &QComboBox::currentTextChanged, this, &NetworkRequestTool::onBodyTypeComboChanged); + 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.table_body, &QTableWidget::cellChanged, this, &NetworkRequestTool::onBodyParamTypeChanged); // Connect table cell changed signal to listen for Content-Type header changes @@ -350,21 +484,30 @@ 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") { 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); + 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 @@ -375,19 +518,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(); } } @@ -399,7 +543,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); } @@ -407,10 +550,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(); } } @@ -484,60 +642,47 @@ 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(); - - 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) + // 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) { - // 示例代码,此工具无这两种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; + 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(); + // Create network request 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); 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)); } + else + { + m_currentTaskId = 0; + } } RequestType NetworkRequestTool::getRequestType() @@ -651,50 +796,148 @@ 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()) + .environment(m_envStore.activeVariables()) + .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) { + 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) { return; } clearResponse(); + m_lastResponseBody.clear(); + m_isResponseJson = false; + m_searchSelections.clear(); + m_currentSearchHit = -1; if (rsp->isSuccess()) { displayResponseHeaders(rsp->headers); 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(); + m_lastResponseBody = rsp->body; + m_isResponseJson = !doc.isNull(); + 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()); + m_lastResponseBody = rsp->body; appendToResponseBody(rsp->body, QColor(16, 124, 16)); } else { + m_lastResponseBody = rsp->body; appendToResponseBody(rsp->body, QColor(16, 124, 16)); } + + displayResponseCookies(rsp->cookies); } else { @@ -710,6 +953,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 +1047,15 @@ void NetworkRequestTool::clearResponse() { clearResponseBody(); clearResponseHeaders(); + clearResponseCookies(); +} + +void NetworkRequestTool::clearResponseCookies() +{ + if (ui.textEdit_response_cookies) + { + ui.textEdit_response_cookies->clear(); + } } void NetworkRequestTool::clearResponseBody() @@ -867,6 +1143,11 @@ void NetworkRequestTool::onRemoveHeader() void NetworkRequestTool::onAbortTask() { + if (m_currentTaskId != 0) + { + NetworkRequestManager::globalInstance()->stopRequest(m_currentTaskId); + m_currentTaskId = 0; + } } void NetworkRequestTool::onAbortAllTask() @@ -1119,26 +1400,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 - { - ui.stackedWidget_body->setCurrentWidget(ui.page_form); - ui.cmb_raw_type->setEnabled(false); - } -} - void NetworkRequestTool::onAddBodyParam() { int row = ui.table_body->rowCount(); @@ -1284,7 +1545,7 @@ void NetworkRequestTool::onSettingsClicked() authLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); auto *authTypeCombo = new QComboBox(); - authTypeCombo->addItems({"None", "Basic", "Bearer"}); + authTypeCombo->addItems({"None", "Basic", "Bearer", "ApiKey", "OAuth2"}); authTypeCombo->setCurrentText(m_settings.authType); auto *authUserEdit = new QLineEdit(m_settings.authUsername); authUserEdit->setPlaceholderText("Username"); @@ -1293,24 +1554,96 @@ 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); + + // 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); - // Hide the label row when the field is hidden so the layout collapses + authApiKeyEdit->setVisible(isApiKey); + authApiValueEdit->setVisible(isApiKey); + authApiLocCombo->setVisible(isApiKey); + 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); @@ -1402,6 +1735,17 @@ 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.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()); @@ -1416,20 +1760,94 @@ 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") + { + // 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(resolve(m_settings.authApiKey), resolve(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); + } + 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 } 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; @@ -1459,6 +1877,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; @@ -1499,6 +2183,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)); @@ -1511,8 +2226,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")); @@ -1538,5 +2256,582 @@ 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(); } + +// ─── Response toolbar (M3) ─────────────────────────────────────────────────── + +void NetworkRequestTool::buildResponseToolbar() +{ + // --- 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(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->setObjectName("btn_resp_nav"); + btnPrev->setFixedSize(24, 24); + btnPrev->setToolTip("Previous match"); + hLayout->addWidget(btnPrev); + + auto *btnNext = new QPushButton(">"); + btnNext->setObjectName("btn_resp_nav"); + btnNext->setFixedSize(24, 24); + btnNext->setToolTip("Next match"); + hLayout->addWidget(btnNext); + + // --- 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); + + // 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 for search, copy, save, pretty + 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(); +} + +// ─── 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(); + collToolbar->setObjectName("collectionToolbar"); + auto *tbLayout = new QHBoxLayout(collToolbar); + tbLayout->setContentsMargins(0, 4, 0, 2); + tbLayout->setSpacing(4); + + auto *btnNewColl = new QPushButton("+Coll"); + btnNewColl->setObjectName("btn_coll"); + btnNewColl->setToolTip("New collection"); + btnNewColl->setFixedHeight(24); + tbLayout->addWidget(btnNewColl); + + auto *btnAddFolder = new QPushButton("+Folder"); + btnAddFolder->setObjectName("btn_coll"); + btnAddFolder->setToolTip("Add folder"); + btnAddFolder->setFixedHeight(24); + tbLayout->addWidget(btnAddFolder); + + auto *btnAddReq = new QPushButton("+Req"); + btnAddReq->setObjectName("btn_coll"); + btnAddReq->setToolTip("Add current request to collection"); + 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(24); + tbLayout->addWidget(btnImport); + + auto *btnExport = new QPushButton("Export"); + btnExport->setObjectName("btn_coll_secondary"); + btnExport->setToolTip("Export to Postman v2.1"); + 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); + 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() + ? QStringLiteral("Untitled 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 d3730bb..2622821 100644 --- a/samples/networkrequesttool/networkrequesttool.h +++ b/samples/networkrequesttool/networkrequesttool.h @@ -4,12 +4,17 @@ #include #include "ui_NetworkRequestTool.h" #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; @@ -46,10 +51,23 @@ 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" + + // 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 @@ -73,6 +91,23 @@ private slots: void onAddHeader(); void onRemoveHeader(); 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); + + // 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); @@ -80,7 +115,6 @@ private slots: void onAbortAllTask(); void onAddBodyParam(); void onRemoveBodyParam(); - void onBodyTypeComboChanged(const QString &type); void onBodyParamTypeChanged(int row, int column); private: @@ -91,12 +125,19 @@ 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(); + 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 +147,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); @@ -113,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(); @@ -123,6 +167,16 @@ private slots: bool isDefaultHeader(const QString &strHeader); QString storageDir(); void ensureStorageDir(); + void loadEnvironments(); + void saveEnvironments(); + void populateEnvironmentCombo(); + void buildResponseToolbar(); + void buildCollectionPanel(); + void saveCollection(); + void loadCollection(); + void populateCollectionTree(); + void doResponseSearch(); + void navigateSearchHit(int delta); private: #ifdef QT_MTNETWORK_UNIT_TEST @@ -140,7 +194,24 @@ private slots: QMap kvPairs; QListWidgetItem *currentHistoryItem; RequestSettings m_settings; + EnvironmentStore m_envStore; + 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; + 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 }; } // namespace QtNetworkRequest diff --git a/scripts/test.bat b/scripts/test.bat new file mode 100644 index 0000000..fb0d9a2 --- /dev/null +++ b/scripts/test.bat @@ -0,0 +1,191 @@ +@echo off +setlocal + +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 ---- 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 ( + 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 (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" 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 "%REPO_ROOT%\build" ( + echo Removing existing build directory... + rmdir /s /q "%REPO_ROOT%\build" + ) +) + +REM ---- Configure ---- +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 +) + +REM ---- Build ---- +if %SKIP_BUILD%==0 ( + echo Building project... + 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]... +) + +REM ---- Run tests ---- +echo Running tests with ctest... +pushd "%REPO_ROOT%\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 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. +endlocal +exit /b 0 diff --git a/source/CMakeLists.txt b/source/CMakeLists.txt index 2094081..fccca7c 100644 --- a/source/CMakeLists.txt +++ b/source/CMakeLists.txt @@ -20,6 +20,11 @@ add_library(QNetworkRequest SHARED progressthrottle.cpp networkrequestregistry.cpp requeststrategies.cpp + environment.cpp + oauth2tokencache.cpp + collectionmodel.cpp + postmanconverter.cpp + environmentstore.cpp # ── Private headers (AUTOMOC) ──────────────────────────────────────────── memorymappedfile.h @@ -46,10 +51,16 @@ 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 ${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 + ${CMAKE_SOURCE_DIR}/include/environmentstore.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/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/networkcommonrequest.cpp b/source/networkcommonrequest.cpp index 2ba58db..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" @@ -59,7 +63,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 +89,28 @@ 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; + } + + // 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, @@ -100,7 +126,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 +138,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 +219,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) @@ -217,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 b00ecc9..0d16429 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" @@ -10,6 +11,7 @@ #include "networkrequestmanager.h" #include "networkrequestregistry.h" #include "sharedcookiejar.h" +#include "environment.h" #include "qtcompat.h" using namespace QtNetworkRequest; @@ -386,16 +388,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 @@ -430,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); @@ -466,6 +523,81 @@ 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: + case AuthType::OAuth2: + { + 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 +607,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>(); + } } } @@ -492,6 +632,11 @@ 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()) + applyEnvironment(*m_upContext, m_upContext->environment); m_url = QUrl(m_upContext->url); } } diff --git a/source/networkrequest.h b/source/networkrequest.h index 0ab67e8..b9ac6c7 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(); @@ -115,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; 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 44d3bef..4db12ea 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -48,6 +48,9 @@ set_tests_properties(UnitTests PROPERTIES TIMEOUT 120) add_executable(BuilderTests main_requestbuilder.cpp test_requestbuilder.cpp + test_bodytype.cpp + test_environment.cpp + httptestserver.cpp ) target_link_libraries(BuilderTests @@ -72,6 +75,68 @@ 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 +# ══════════════════════════════════════════════════════════════════════════════ +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/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/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_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 57e9233..624cdae 100644 --- a/test/main_requestbuilder.cpp +++ b/test/main_requestbuilder.cpp @@ -1,6 +1,9 @@ #include #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 // tests. Kept in its own executable so the fluent-API contract tests run @@ -8,7 +11,16 @@ 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); + EnvironmentTests environmentTests; + status |= QTest::qExec(&environmentTests, 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..cba156c --- /dev/null +++ b/test/test_auth.cpp @@ -0,0 +1,485 @@ +#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); +} + +// ============================================================================ +// 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 new file mode 100644 index 0000000..ef7fd4f --- /dev/null +++ b/test/test_auth.h @@ -0,0 +1,52 @@ +#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(); + + // OAuth2 integration tests (M2) + void testOAuth2ClientCredentials(); + void testOAuth2ClientCredentialsCache(); + void testOAuth2PasswordGrant(); + void testOAuth2RefreshToken(); + void testOAuth2TokenExpired(); + +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_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 diff --git a/test/test_qtrequester.cpp b/test/test_qtrequester.cpp index 7852ec2..fd09a4a 100644 --- a/test/test_qtrequester.cpp +++ b/test/test_qtrequester.cpp @@ -15,9 +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" @@ -152,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"; @@ -160,6 +184,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 // --------------------------------------------------------------------------- @@ -395,3 +518,713 @@ 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")); + } +} + +// --------------------------------------------------------------------------- +// 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 +} + +// =========================================================================== +// 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 ebb48c9..c221248 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(); @@ -31,6 +35,45 @@ private slots: void testUpdateBodyTypeFromContentType(); void testJsonHighlighter(); void testXmlHighlighter(); + + // M1 environment + void test_envDropdownSwitch(); + + // 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 diff --git a/test/test_requestbuilder.cpp b/test/test_requestbuilder.cpp index 8181159..a613d8f 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,81 @@ 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")); +} + +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 4261f18..91cd8be 100644 --- a/test/test_requestbuilder.h +++ b/test/test_requestbuilder.h @@ -19,6 +19,10 @@ private slots: void testTaskAndSessionIds(); void testConfigsAndUserContext(); void testBuildConsumesBuilder(); + void testQueryParamBuilder(); + void testQueryParamsMapBuilder(); + void testAuthBuilder(); + void testBodyFormDataBuilder(); }; #endif // TEST_REQUESTBUILDER_H