From 7ec8659381229bfe9028e48238e69f7cd2ceea58 Mon Sep 17 00:00:00 2001 From: Nivedin <53208152+nivedin@users.noreply.github.com> Date: Thu, 7 Mar 2024 12:50:44 +0530 Subject: [PATCH] feat: request variables (#3825) Co-authored-by: jamesgeorge007 --- .../collection-level-headers-auth-coll.json | 24 +- .../collections/env-flag-tests-coll.json | 5 +- .../samples/collections/fails-coll.json | 10 +- .../samples/collections/malformed-coll.json | 12 +- .../samples/collections/notjson-coll.txt | 7 +- .../samples/collections/passes-coll.json | 10 +- ...e-req-script-env-var-persistence-coll.json | 5 +- .../collections/req-body-env-vars-coll.json | 5 +- .../samples/collections/secret-envs-coll.json | 18 +- .../secret-envs-persistence-coll.json | 18 +- ...ecret-envs-persistence-scripting-coll.json | 5 +- .../hoppscotch-common/assets/scss/styles.scss | 18 +- packages/hoppscotch-common/locales/en.json | 2 + .../src/components/embeds/index.vue | 18 +- .../src/components/http/Authorization.vue | 21 +- .../src/components/http/Body.vue | 4 + .../src/components/http/BodyParameters.vue | 6 + .../src/components/http/CodegenModal.vue | 22 +- .../src/components/http/Headers.vue | 13 +- .../components/http/OAuth2Authorization.vue | 30 +- .../src/components/http/Parameters.vue | 6 + .../src/components/http/Request.vue | 1 + .../src/components/http/RequestOptions.vue | 58 ++- .../src/components/http/RequestVariables.vue | 412 ++++++++++++++++++ .../src/components/http/URLEncodedParams.vue | 8 +- .../components/http/authorization/ApiKey.vue | 16 +- .../components/http/authorization/Basic.vue | 6 + .../interceptors/ErrorPlaceholder.vue | 40 +- .../src/components/share/CustomizeModal.vue | 7 +- .../src/components/share/Modal.vue | 7 +- .../src/components/share/index.vue | 17 +- .../src/components/share/templates/Embeds.vue | 9 +- .../src/components/smart/EnvInput.vue | 89 ++-- .../src/composables/codemirror.ts | 2 +- .../src/helpers/RESTRequest.ts | 293 ------------- .../src/helpers/RequestRunner.ts | 82 +++- .../helpers/curl/__tests__/curlparser.spec.js | 27 ++ .../src/helpers/curl/curlparser.ts | 1 + .../editor/extensions/HoppEnvironment.ts | 124 +++++- .../helpers/import-export/import/insomnia.ts | 29 +- .../helpers/import-export/import/openapi.ts | 26 ++ .../helpers/import-export/import/postman.ts | 22 + .../src/helpers/preRequest.ts | 8 +- .../src/helpers/rest/default.ts | 1 + .../src/helpers/utils/EffectiveURL.ts | 37 +- .../hoppscotch-common/src/newstore/history.ts | 1 + .../src/newstore/settings.ts | 2 + .../inspectors/environment.inspector.ts | 72 ++- .../persistence/__tests__/__mocks__/index.ts | 9 +- .../persistence/validation-schemas/index.ts | 32 +- packages/hoppscotch-data/src/rest/index.ts | 26 +- packages/hoppscotch-data/src/rest/v/1.ts | 4 +- packages/hoppscotch-data/src/rest/v/2.ts | 50 +++ .../collections/collections.platform.ts | 2 + 54 files changed, 1273 insertions(+), 506 deletions(-) create mode 100644 packages/hoppscotch-common/src/components/http/RequestVariables.vue delete mode 100644 packages/hoppscotch-common/src/helpers/RESTRequest.ts create mode 100644 packages/hoppscotch-data/src/rest/v/2.ts diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json index 9e6a5d631..03d2598ee 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/collection-level-headers-auth-coll.json @@ -17,7 +17,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestD", "params": [], @@ -40,7 +40,8 @@ "body": { "contentType": null, "body": null - } + }, + "requestVariables": [] } ], "auth": { @@ -52,7 +53,7 @@ ], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestC", "params": [], @@ -67,7 +68,8 @@ "body": { "contentType": null, "body": null - } + }, + "requestVariables": [] } ], "auth": { @@ -88,7 +90,7 @@ ], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -104,6 +106,7 @@ "contentType": null, "body": null }, + "requestVariables": [], "id": "clpttpdq00003qp16kut6doqv" } ], @@ -116,7 +119,7 @@ ], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], @@ -132,6 +135,7 @@ "contentType": null, "body": null }, + "requestVariables": [], "id": "clpttpdq00003qp16kut6doqv" } ], @@ -158,7 +162,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -174,6 +178,7 @@ "contentType": null, "body": null }, + "requestVariables": [], "id": "clpttpdq00003qp16kut6doqv" } ], @@ -186,7 +191,7 @@ ], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], @@ -202,6 +207,7 @@ "contentType": null, "body": null }, + "requestVariables": [], "id": "clpttpdq00003qp16kut6doqv" } ], @@ -218,4 +224,4 @@ "token": "BearerToken" } } -] \ No newline at end of file +] diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json index 6a6eaf000..879264f7f 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/env-flag-tests-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "<>", "name": "test1", "params": [], @@ -16,7 +16,8 @@ "body": { "contentType": "application/json", "body": "{\n \"<>\":\"<>\"\n}" - } + }, + "requestVariables": [] } ] } diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json index bdc0657cf..fec2e4e89 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/fails-coll.json @@ -5,7 +5,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -23,10 +23,11 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [], }, { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.dio/<>", "name": "success", "params": [], @@ -44,7 +45,8 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [] } ] } diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json index 5e80935c2..9050f523c 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json @@ -2,9 +2,9 @@ { "v": 1, "folders": [], - "requests": + "requests": { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "fail", "params": [], @@ -22,10 +22,11 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [], }, { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -43,7 +44,8 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [] } ] } diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/notjson-coll.txt b/packages/hoppscotch-cli/src/__tests__/samples/collections/notjson-coll.txt index 8081ea880..bd979971e 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/notjson-coll.txt +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/notjson-coll.txt @@ -2,9 +2,9 @@ { "v": 1, "folders": [], - "requests": + "requests": { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "fail", "params": [], @@ -22,7 +22,8 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [] } ] } diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json index aea1fc356..fb448bd17 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/passes-coll.json @@ -5,7 +5,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -23,10 +23,11 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [] }, { - "v": "1", + "v": "2", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -44,7 +45,8 @@ "body": { "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" - } + }, + "requestVariables": [] } ] } diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json index 78d16cb82..c0197b092 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/pre-req-script-env-var-persistence-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "sample-req", @@ -13,7 +13,8 @@ "headers": [], "endpoint": "https://echo.hoppscotch.io", "testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")", - "preRequestScript": "pw.env.set(\"variable\", \"value\");" + "preRequestScript": "pw.env.set(\"variable\", \"value\");", + "requestVariables": [] } ], "auth": { "authType": "inherit", "authActive": true }, diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json index 1bcb22649..0e5f4590b 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/req-body-env-vars-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "name": "test-request", "endpoint": "https://echo.hoppscotch.io", "method": "POST", @@ -19,7 +19,8 @@ "body": "{\n \"firstName\": \"<>\",\n \"lastName\": \"<>\",\n \"greetText\": \"<>, <>\",\n \"fullName\": \"<>\",\n \"id\": \"<>\"\n}" }, "preRequestScript": "", - "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});" + "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});", + "requestVariables": [] } ], "auth": { diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json index f07da4d2b..57889f26b 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-headers", @@ -17,12 +17,13 @@ "active": true } ], + "requestVariables": [], "endpoint": "<>/headers", "testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.get(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.get(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})", "preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true }, "body": { "body": "{\n \"secretBodyKey\": \"<>\"\n}", @@ -32,12 +33,13 @@ "method": "POST", "params": [], "headers": [], + "requestVariables": [], "endpoint": "<>/post", "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", "preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-query-params", @@ -50,12 +52,13 @@ } ], "headers": [], + "requestVariables": [], "endpoint": "<>/get", "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", "preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "basic", "password": "<>", @@ -67,12 +70,13 @@ "method": "GET", "params": [], "headers": [], + "requestVariables": [], "endpoint": "<>/basic-auth/<>/<>", "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", "preRequestScript": "" }, { - "v": "1", + "v": "2", "auth": { "token": "<>", "authType": "bearer", @@ -85,18 +89,20 @@ "method": "GET", "params": [], "headers": [], + "requestVariables": [], "endpoint": "<>/bearer", "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.get(\"secretBearerToken\")\n const preReqSecretBearerToken = pw.env.get(\"preReqSecretBearerToken\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", "preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-fallback", "method": "GET", "params": [], "headers": [], + "requestVariables": [], "endpoint": "<>", "testScript": "pw.test(\"Returns an empty string if the value for a secret environment variable is not found in the system environment\", () => {\n pw.expect(pw.env.get(\"nonExistentValueInSystemEnv\")).toBe(\"\")\n})", "preRequestScript": "" diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json index 809233e85..04a32e680 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true @@ -16,6 +16,7 @@ "name": "test-secret-headers", "method": "GET", "params": [], + "requestVariables": [], "headers": [ { "key": "Secret-Header-Key", @@ -28,7 +29,7 @@ "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true @@ -40,6 +41,7 @@ "name": "test-secret-headers-overrides", "method": "GET", "params": [], + "requestVariables": [], "headers": [ { "key": "Secret-Header-Key", @@ -52,7 +54,7 @@ "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true @@ -64,13 +66,14 @@ "name": "test-secret-body", "method": "POST", "params": [], + "requestVariables": [], "headers": [], "endpoint": "<>/post", "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", "preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "none", "authActive": true @@ -88,13 +91,14 @@ "active": true } ], + "requestVariables": [], "headers": [], "endpoint": "<>/get", "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", "preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" }, { - "v": "1", + "v": "2", "auth": { "authType": "basic", "password": "<>", @@ -108,13 +112,14 @@ "name": "test-secret-basic-auth", "method": "GET", "params": [], + "requestVariables": [], "headers": [], "endpoint": "<>/basic-auth/<>/<>", "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", "preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}" }, { - "v": "1", + "v": "2", "auth": { "token": "<>", "authType": "bearer", @@ -129,6 +134,7 @@ "name": "test-secret-bearer-auth", "method": "GET", "params": [], + "requestVariables": [], "headers": [], "endpoint": "<>/bearer", "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.resolve(\"<>\")\n const preReqSecretBearerToken = pw.env.resolve(\"<>\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json index 0bb77e2b1..76ab9ea89 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/secret-envs-persistence-scripting-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": [ { - "v": "1", + "v": "2", "endpoint": "https://httpbin.org/post", "name": "req", "params": [], @@ -22,7 +22,8 @@ "body": { "contentType": "application/json", "body": "{\n \"key\": \"<>\"\n}" - } + }, + "requestVariables": [] } ], "auth": { "authType": "inherit", "authActive": false }, diff --git a/packages/hoppscotch-common/assets/scss/styles.scss b/packages/hoppscotch-common/assets/scss/styles.scss index 5b898b127..258992de7 100644 --- a/packages/hoppscotch-common/assets/scss/styles.scss +++ b/packages/hoppscotch-common/assets/scss/styles.scss @@ -563,12 +563,22 @@ details[open] summary .indicator { .env-highlight { @apply text-accentContrast; - &.env-found { - @apply bg-accentDark; - @apply hover:bg-accent; + &.request-variable-highlight { + @apply bg-amber-500; + @apply hover:bg-amber-600; } - &.env-not-found { + &.environment-variable-highlight { + @apply bg-green-500; + @apply hover:bg-green-600; + } + + &.global-variable-highlight { + @apply bg-blue-500; + @apply hover:bg-blue-600; + } + + &.environment-not-found-highlight { @apply bg-red-500; @apply hover:bg-red-600; } diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 4160bb7ce..1b07f6eb9 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -239,6 +239,7 @@ "pending_invites": "There are no pending invites for this team", "profile": "Login to view your profile", "protocols": "Protocols are empty", + "request_variables": "This request does not have any request variables", "schema": "Connect to a GraphQL endpoint to view schema", "secret_environments": "Secrets are not synced to Hoppscotch", "shared_requests": "Shared requests are empty", @@ -560,6 +561,7 @@ "raw_body": "Raw Request Body", "rename": "Rename Request", "renamed": "Request renamed", + "request_variables": "Request variables", "run": "Run", "save": "Save", "save_as": "Save as", diff --git a/packages/hoppscotch-common/src/components/embeds/index.vue b/packages/hoppscotch-common/src/components/embeds/index.vue index 4e338553a..adbcedf78 100644 --- a/packages/hoppscotch-common/src/components/embeds/index.vue +++ b/packages/hoppscotch-common/src/components/embeds/index.vue @@ -32,9 +32,13 @@ {{ tab.document.request.method }}
- {{ tab.document.request.endpoint }} +
@@ -69,6 +73,7 @@ v-model="tab.document.request" v-model:option-tab="selectedOptionTab" :properties="properties" + :envs="tabRequestVariables" /> { return `${shortcodeBaseURL}/r/${props.sharedRequestID}` }) +const tabRequestVariables = computed(() => { + return tab.value.document.request.requestVariables.map(({ key, value }) => ({ + key, + value, + secret: false, + sourceEnv: "RequestVariable", + })) +}) + const { subscribeToStream } = useStreamSubscriber() const newSendRequest = async () => { diff --git a/packages/hoppscotch-common/src/components/http/Authorization.vue b/packages/hoppscotch-common/src/components/http/Authorization.vue index f1a7b0a60..4108d3a14 100644 --- a/packages/hoppscotch-common/src/components/http/Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/Authorization.vue @@ -150,7 +150,7 @@
- +
@@ -169,17 +169,26 @@
- +
- +
- +
- +
() const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/Body.vue b/packages/hoppscotch-common/src/components/http/Body.vue index 75c15ca95..0909de822 100644 --- a/packages/hoppscotch-common/src/components/http/Body.vue +++ b/packages/hoppscotch-common/src/components/http/Body.vue @@ -100,10 +100,12 @@ () const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/BodyParameters.vue b/packages/hoppscotch-common/src/components/http/BodyParameters.vue index c8f13c4b1..d12a8b66f 100644 --- a/packages/hoppscotch-common/src/components/http/BodyParameters.vue +++ b/packages/hoppscotch-common/src/components/http/BodyParameters.vue @@ -65,6 +65,8 @@ () const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/CodegenModal.vue b/packages/hoppscotch-common/src/components/http/CodegenModal.vue index 1c7fa178f..510e00ae8 100644 --- a/packages/hoppscotch-common/src/components/http/CodegenModal.vue +++ b/packages/hoppscotch-common/src/components/http/CodegenModal.vue @@ -188,11 +188,25 @@ const copyCodeIcon = refAutoReset( const requestCode = computed(() => { const aggregateEnvs = getAggregateEnvs() + const requestVariables = request.value.requestVariables.map( + (requestVariable) => { + if (requestVariable.active) + return { + key: requestVariable.key, + value: requestVariable.value, + secret: false, + } + return {} + } + ) const env: Environment = { v: 1, id: "env", name: "Env", - variables: aggregateEnvs, + variables: [ + ...(requestVariables as Environment["variables"]), + ...aggregateEnvs, + ], } const effectiveRequest = getEffectiveRESTRequest(request.value, env) @@ -212,6 +226,12 @@ const requestCode = computed(() => { active: true, })), endpoint: effectiveRequest.effectiveFinalURL, + requestVariables: effectiveRequest.effectiveFinalRequestVariables.map( + (requestVariable) => ({ + ...requestVariable, + active: true, + }) + ), }) ) diff --git a/packages/hoppscotch-common/src/components/http/Headers.vue b/packages/hoppscotch-common/src/components/http/Headers.vue index fb46c4aee..13d949e51 100644 --- a/packages/hoppscotch-common/src/components/http/Headers.vue +++ b/packages/hoppscotch-common/src/components/http/Headers.vue @@ -26,7 +26,7 @@ @click="clearContent()" /> () const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue index ee9d2f1c0..a6dbc0a51 100644 --- a/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue +++ b/packages/hoppscotch-common/src/components/http/OAuth2Authorization.vue @@ -6,31 +6,52 @@ :styles=" hasAccessTokenOrAuthURL ? 'pointer-events-none opacity-70' : '' " + :auto-complete-env="true" placeholder="OpenID Connect Discovery URL" + :envs="envs" />
+ :envs="envs" + />
- +
- +
- +
() const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/Parameters.vue b/packages/hoppscotch-common/src/components/http/Parameters.vue index 202cbf631..9e8463f11 100644 --- a/packages/hoppscotch-common/src/components/http/Parameters.vue +++ b/packages/hoppscotch-common/src/components/http/Parameters.vue @@ -87,6 +87,8 @@ :inspection-results=" getInspectorResult(parameterKeyResults, index) " + :auto-complete-env="true" + :envs="envs" @change=" updateParam(index, { id: param.id, @@ -102,6 +104,8 @@ :inspection-results=" getInspectorResult(parameterValueResults, index) " + :auto-complete-env="true" + :envs="envs" @change=" updateParam(index, { id: param.id, @@ -209,6 +213,7 @@ import { InspectionService, InspectorResult } from "~/services/inspection" import { RESTTabService } from "~/services/tab/rest" import { useNestedSetting } from "~/composables/settings" import { toggleNestedSetting } from "~/newstore/settings" +import { AggregateEnvironment } from "~/newstore/environments" const colorMode = useColorMode() @@ -242,6 +247,7 @@ useCodemirror( const props = defineProps<{ modelValue: HoppRESTParam[] + envs?: AggregateEnvironment[] }>() const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/Request.vue b/packages/hoppscotch-common/src/components/http/Request.vue index 9191057ff..aa792aca3 100644 --- a/packages/hoppscotch-common/src/components/http/Request.vue +++ b/packages/hoppscotch-common/src/components/http/Request.vue @@ -56,6 +56,7 @@ v-model="tab.document.request.endpoint" :placeholder="`${t('request.url')}`" :auto-complete-source="userHistories" + :auto-complete-env="true" :inspection-results="tabResults" @paste="onPasteUrl($event)" @enter="newSendRequest" diff --git a/packages/hoppscotch-common/src/components/http/RequestOptions.vue b/packages/hoppscotch-common/src/components/http/RequestOptions.vue index 06a96d52f..6f9afe1bb 100644 --- a/packages/hoppscotch-common/src/components/http/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/http/RequestOptions.vue @@ -5,48 +5,51 @@ render-inactive-tabs > - + + + + @@ -77,6 +89,7 @@ import { useVModel } from "@vueuse/core" import { computed } from "vue" import { defineActionHandler } from "~/helpers/actions" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { AggregateEnvironment } from "~/newstore/environments" const VALID_OPTION_TABS = [ "params", @@ -85,6 +98,7 @@ const VALID_OPTION_TABS = [ "authorization", "preRequestScript", "tests", + "requestVariables", ] as const export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number] @@ -98,6 +112,7 @@ const props = withDefaults( optionTab: RESTOptionTabs properties?: string[] inheritedProperties?: HoppInheritedProperty + envs?: AggregateEnvironment[] }>(), { optionTab: "params", @@ -116,22 +131,27 @@ const changeOptionTab = (e: RESTOptionTabs) => { selectedOptionTab.value = e } -const newActiveParamsCount$ = computed(() => { - const e = request.value.params.filter( - (x) => x.active && (x.key !== "" || x.value !== "") +const newActiveParamsCount = computed(() => { + const count = request.value.params.filter( + (x) => x.active && (x.key || x.value) ).length - if (e === 0) return null - return `${e}` + return count ? count : null }) -const newActiveHeadersCount$ = computed(() => { - const e = request.value.headers.filter( - (x) => x.active && (x.key !== "" || x.value !== "") +const newActiveHeadersCount = computed(() => { + const count = request.value.headers.filter( + (x) => x.active && (x.key || x.value) ).length - if (e === 0) return null - return `${e}` + return count ? count : null +}) + +const newActiveRequestVariablesCount = computed(() => { + const count = request.value.requestVariables.filter( + (x) => x.active && (x.key || x.value) + ).length + return count ? count : null }) defineActionHandler("request.open-tab", ({ tab }) => { diff --git a/packages/hoppscotch-common/src/components/http/RequestVariables.vue b/packages/hoppscotch-common/src/components/http/RequestVariables.vue new file mode 100644 index 000000000..68bc8ec39 --- /dev/null +++ b/packages/hoppscotch-common/src/components/http/RequestVariables.vue @@ -0,0 +1,412 @@ + + + diff --git a/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue index 9f1418249..5c9139a75 100644 --- a/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue +++ b/packages/hoppscotch-common/src/components/http/URLEncodedParams.vue @@ -21,7 +21,7 @@ @click="clearContent()" /> () const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue index 52eff4ed8..619eed912 100644 --- a/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue +++ b/packages/hoppscotch-common/src/components/http/authorization/ApiKey.vue @@ -1,9 +1,19 @@ @@ -17,11 +21,13 @@ import { useI18n } from "@composables/i18n" import { HoppRESTAuthBasic } from "@hoppscotch/data" import { useVModel } from "@vueuse/core" +import { AggregateEnvironment } from "~/newstore/environments" const t = useI18n() const props = defineProps<{ modelValue: HoppRESTAuthBasic + envs?: AggregateEnvironment[] }>() const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/interceptors/ErrorPlaceholder.vue b/packages/hoppscotch-common/src/components/interceptors/ErrorPlaceholder.vue index abd074034..351ad8406 100644 --- a/packages/hoppscotch-common/src/components/interceptors/ErrorPlaceholder.vue +++ b/packages/hoppscotch-common/src/components/interceptors/ErrorPlaceholder.vue @@ -21,28 +21,24 @@
- - - - - - + +
diff --git a/packages/hoppscotch-common/src/components/share/CustomizeModal.vue b/packages/hoppscotch-common/src/components/share/CustomizeModal.vue index 87a6ee85e..87d6e6c98 100644 --- a/packages/hoppscotch-common/src/components/share/CustomizeModal.vue +++ b/packages/hoppscotch-common/src/components/share/CustomizeModal.vue @@ -290,7 +290,12 @@ const widgets: Widget[] = [ }, ] -type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" +type EmbedTabs = + | "params" + | "bodyParams" + | "headers" + | "authorization" + | "requestVariables" type EmbedOption = { selectedTab: EmbedTabs diff --git a/packages/hoppscotch-common/src/components/share/Modal.vue b/packages/hoppscotch-common/src/components/share/Modal.vue index 942e5bc8d..38c0c1b40 100644 --- a/packages/hoppscotch-common/src/components/share/Modal.vue +++ b/packages/hoppscotch-common/src/components/share/Modal.vue @@ -56,7 +56,12 @@ import { useI18n } from "~/composables/i18n" const t = useI18n() -type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" +type EmbedTabs = + | "params" + | "bodyParams" + | "headers" + | "authorization" + | "requestVariables" type EmbedOption = { selectedTab: EmbedTabs diff --git a/packages/hoppscotch-common/src/components/share/index.vue b/packages/hoppscotch-common/src/components/share/index.vue index 34fc95967..164318473 100644 --- a/packages/hoppscotch-common/src/components/share/index.vue +++ b/packages/hoppscotch-common/src/components/share/index.vue @@ -173,6 +173,11 @@ const embedOptions = ref({ label: t("tab.authorization"), enabled: false, }, + { + value: "requestVariables", + label: t("tab.variables"), + enabled: false, + }, ], theme: "system", }) @@ -223,7 +228,12 @@ const currentUser = useReadonlyStream( const step = ref(1) -type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" +type EmbedTabs = + | "params" + | "bodyParams" + | "headers" + | "authorization" + | "requestVariables" type EmbedOption = { selectedTab: EmbedTabs @@ -369,6 +379,11 @@ const displayCustomizeRequestModal = ( label: t("tab.authorization"), enabled: false, }, + { + value: "requestVariables", + label: t("tab.variables"), + enabled: false, + }, ], theme: "system", } diff --git a/packages/hoppscotch-common/src/components/share/templates/Embeds.vue b/packages/hoppscotch-common/src/components/share/templates/Embeds.vue index dfdb6b31c..b2ce645fa 100644 --- a/packages/hoppscotch-common/src/components/share/templates/Embeds.vue +++ b/packages/hoppscotch-common/src/components/share/templates/Embeds.vue @@ -28,7 +28,7 @@
(), { modelValue: "", @@ -124,6 +123,7 @@ const props = withDefaults( inspectionResults: undefined, contextMenuEnabled: true, secret: false, + autoCompleteEnvSource: false, } ) @@ -353,29 +353,62 @@ watch( let clipboardEv: ClipboardEvent | null = null let pastedValue: string | null = null -const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref< +const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref< AggregateEnvironment[] > +const tabs = useService(RESTTabService) + const envVars = computed(() => { - return props.envs - ? props.envs.map((x) => { - if (x.secret) { - return { - key: x.key, - sourceEnv: "source" in x ? x.source : null, - value: "********", - } - } - return { - key: x.key, - value: x.value, - sourceEnv: "source" in x ? x.source : null, - } - }) - : aggregateEnvs.value + if (props.envs) { + return props.envs.map((x) => { + const { key, secret } = x + const value = secret ? "********" : x.value + const sourceEnv = "sourceEnv" in x ? x.sourceEnv : null + return { + key, + value, + sourceEnv, + secret, + } + }) + } + return [ + ...tabs.currentActiveTab.value.document.request.requestVariables.map( + ({ active, key, value }) => + active + ? { + key, + value, + sourceEnv: "RequestVariable", + secret: false, + } + : ({} as AggregateEnvironment) + ), + ...aggregateEnvs.value, + ] }) +function envAutoCompletion(context: CompletionContext) { + const options = (envVars.value ?? []) + .map((env) => ({ + label: env?.key ? `<<${env.key}>>` : "", + info: env?.value ?? "", + apply: env?.key ? `<<${env.key}>>` : "", + })) + .filter((x) => x) + + const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1) + const textBefore = context.state.sliceDoc(nodeBefore.from, context.pos) + const tagBefore = /<<\w*$/.exec(textBefore) + if (!tagBefore && !context.explicit) return null + return { + from: tagBefore ? nodeBefore.from + tagBefore.index : context.pos, + options: options, + validFor: /^(<<\w*)?$/, + } +} + const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) function handleTextSelection() { @@ -469,6 +502,12 @@ const getExtensions = (readonly: boolean): Extension => { } }, }), + props.autoCompleteEnv + ? autocompletion({ + activateOnTyping: true, + override: [envAutoCompletion], + }) + : [], ViewPlugin.fromClass( class { update(update: ViewUpdate) { diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index 58f69e06a..cd4214405 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -243,7 +243,7 @@ export function useCodemirror( if (from === to) return const text = view.value?.state.doc.sliceString(from, to) const { top, left } = view.value?.coordsAtPos(from) - if (text) { + if (text?.trim()) { invokeAction("contextmenu.open", { position: { top, diff --git a/packages/hoppscotch-common/src/helpers/RESTRequest.ts b/packages/hoppscotch-common/src/helpers/RESTRequest.ts deleted file mode 100644 index d44a2d55d..000000000 --- a/packages/hoppscotch-common/src/helpers/RESTRequest.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - FormDataKeyValue, - HoppRESTAuth, - HoppRESTHeader, - HoppRESTParam, - HoppRESTReqBody, - HoppRESTRequest, - RESTReqSchemaVersion, - ValidContentTypes, -} from "@hoppscotch/data" -import { BehaviorSubject, combineLatest, map } from "rxjs" -import { applyBodyTransition } from "~/helpers/rules/BodyTransition" -import { HoppRESTResponse } from "./types/HoppRESTResponse" - -export class RESTRequest { - public v$ = new BehaviorSubject( - RESTReqSchemaVersion - ) - public name$ = new BehaviorSubject("Untitled") - public endpoint$ = new BehaviorSubject("https://echo.hoppscotch.io/") - public params$ = new BehaviorSubject([]) - public headers$ = new BehaviorSubject([]) - public method$ = new BehaviorSubject("GET") - public auth$ = new BehaviorSubject({ - authType: "none", - authActive: true, - }) - public preRequestScript$ = new BehaviorSubject("") - public testScript$ = new BehaviorSubject("") - public body$ = new BehaviorSubject({ - contentType: null, - body: null, - }) - - public response$ = new BehaviorSubject(null) - - get request$() { - // any of above changes construct requests - return combineLatest([ - this.v$, - this.name$, - this.endpoint$, - this.params$, - this.headers$, - this.method$, - this.auth$, - this.preRequestScript$, - this.testScript$, - this.body$, - ]).pipe( - map( - ([ - v, - name, - endpoint, - params, - headers, - method, - auth, - preRequestScript, - testScript, - body, - ]) => ({ - v, - name, - endpoint, - params, - headers, - method, - auth, - preRequestScript, - testScript, - body, - }) - ) - ) - } - - get contentType$() { - return this.body$.pipe(map((body) => body.contentType)) - } - - get bodyContent$() { - return this.body$.pipe(map((body) => body.body)) - } - - get headersCount$() { - return this.headers$.pipe( - map( - (params) => - params.filter((x) => x.active && (x.key !== "" || x.value !== "")) - .length - ) - ) - } - - get paramsCount$() { - return this.params$.pipe( - map( - (params) => - params.filter((x) => x.active && (x.key !== "" || x.value !== "")) - .length - ) - ) - } - - setName(name: string) { - this.name$.next(name) - } - - setEndpoint(newURL: string) { - this.endpoint$.next(newURL) - } - - setMethod(newMethod: string) { - this.method$.next(newMethod) - } - - setParams(entries: HoppRESTParam[]) { - this.params$.next(entries) - } - - addParam(newParam: HoppRESTParam) { - const newParams = this.params$.value.concat(newParam) - this.params$.next(newParams) - } - - updateParam(index: number, updatedParam: HoppRESTParam) { - const newParams = this.params$.value.map((param, i) => - i === index ? updatedParam : param - ) - this.params$.next(newParams) - } - - deleteParam(index: number) { - const newParams = this.params$.value.filter((_, i) => i !== index) - this.params$.next(newParams) - } - - deleteAllParams() { - this.params$.next([]) - } - - setHeaders(entries: HoppRESTHeader[]) { - this.headers$.next(entries) - } - - addHeader(newHeader: HoppRESTHeader) { - const newHeaders = this.headers$.value.concat(newHeader) - this.headers$.next(newHeaders) - } - - updateHeader(index: number, updatedHeader: HoppRESTHeader) { - const newHeaders = this.headers$.value.map((header, i) => - i === index ? updatedHeader : header - ) - this.headers$.next(newHeaders) - } - - deleteHeader(index: number) { - const newHeaders = this.headers$.value.filter((_, i) => i !== index) - this.headers$.next(newHeaders) - } - - deleteAllHeaders() { - this.headers$.next([]) - } - - setContentType(newContentType: ValidContentTypes | null) { - // TODO: persist body evenafter switching content typees - this.body$.next(applyBodyTransition(this.body$.value, newContentType)) - } - - setBody(newBody: string | FormDataKeyValue[] | null) { - const body = { ...this.body$.value } - body.body = newBody - this.body$.next({ ...body }) - } - - addFormDataEntry(entry: FormDataKeyValue) { - if (this.body$.value.contentType !== "multipart/form-data") return {} - const body: HoppRESTReqBody = { - contentType: "multipart/form-data", - body: [...this.body$.value.body, entry], - } - this.body$.next(body) - } - - deleteFormDataEntry(index: number) { - // Only perform update if the current content-type is formdata - if (this.body$.value.contentType !== "multipart/form-data") return {} - - const body: HoppRESTReqBody = { - contentType: "multipart/form-data", - body: [...this.body$.value.body.filter((_, i) => i !== index)], - } - - this.body$.next(body) - } - - updateFormDataEntry(index: number, entry: FormDataKeyValue) { - // Only perform update if the current content-type is formdata - if (this.body$.value.contentType !== "multipart/form-data") return {} - - const body: HoppRESTReqBody = { - contentType: "multipart/form-data", - body: [ - ...this.body$.value.body.map((oldEntry, i) => - i === index ? entry : oldEntry - ), - ], - } - this.body$.next(body) - } - - deleteAllFormDataEntries() { - // Only perform update if the current content-type is formdata - if (this.body$.value.contentType !== "multipart/form-data") return {} - - const body: HoppRESTReqBody = { - contentType: "multipart/form-data", - body: [], - } - this.body$.next(body) - } - - setRequestBody(newBody: HoppRESTReqBody) { - this.body$.next(newBody) - } - - setAuth(newAuth: HoppRESTAuth) { - this.auth$.next(newAuth) - } - - setPreRequestScript(newScript: string) { - this.preRequestScript$.next(newScript) - } - - setTestScript(newScript: string) { - this.testScript$.next(newScript) - } - - updateResponse(response: HoppRESTResponse | null) { - this.response$.next(response) - } - - setRequest(request: HoppRESTRequest) { - this.v$.next(RESTReqSchemaVersion) - this.name$.next(request.name) - this.endpoint$.next(request.endpoint) - this.params$.next(request.params) - this.headers$.next(request.headers) - this.method$.next(request.method) - this.auth$.next(request.auth) - this.preRequestScript$.next(request.preRequestScript) - this.testScript$.next(request.testScript) - this.body$.next(request.body) - } - - getRequest() { - return { - v: this.v$.value, - name: this.name$.value, - endpoint: this.endpoint$.value, - params: this.params$.value, - headers: this.headers$.value, - method: this.method$.value, - auth: this.auth$.value, - preRequestScript: this.preRequestScript$.value, - testScript: this.testScript$.value, - body: this.body$.value, - } - } - - resetRequest() { - this.v$.next(RESTReqSchemaVersion) - this.name$.next("") - this.endpoint$.next("") - this.params$.next([]) - this.headers$.next([]) - this.method$.next("GET") - this.auth$.next({ - authType: "none", - authActive: false, - }) - this.preRequestScript$.next("") - this.testScript$.next("") - this.body$.next({ - contentType: null, - body: null, - }) - } -} diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index 0ca299051..22ed93468 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -1,4 +1,8 @@ -import { Environment } from "@hoppscotch/data" +import { + Environment, + HoppRESTHeaders, + HoppRESTRequestVariable, +} from "@hoppscotch/data" import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox" import { runTestScript } from "@hoppscotch/js-sandbox/web" import * as A from "fp-ts/Array" @@ -65,10 +69,17 @@ const getTestableBody = ( return x } -const combineEnvVariables = (envs: { - global: Environment["variables"] - selected: Environment["variables"] -}) => [...envs.selected, ...envs.global] +const combineEnvVariables = (variables: { + environments: { + selected: Environment["variables"] + global: Environment["variables"] + } + requestVariables: Environment["variables"] +}) => [ + ...variables.requestVariables, + ...variables.environments.selected, + ...variables.environments.global, +] export const executedResponses$ = new Subject< HoppRESTResponse & { type: "success" | "fail " } @@ -122,6 +133,38 @@ const updateEnvironmentsWithSecret = ( return updatedEnv } +/** + * Transforms the environment list to a list with unique keys with value + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ +const filterNonEmptyEnvironmentVariables = ( + envs: Environment["variables"] +): Environment["variables"] => { + const envsMap = new Map() + + envs.forEach((env) => { + if (env.secret) { + envsMap.set(env.key, env) + } else if (envsMap.has(env.key)) { + const existingEnv = envsMap.get(env.key) + + if ( + existingEnv && + "value" in existingEnv && + existingEnv.value === "" && + env.value !== "" + ) { + envsMap.set(env.key, env) + } + } else { + envsMap.set(env.key, env) + } + }) + + return Array.from(envsMap.values()) +} + export function runRESTRequest$( tab: Ref> ): [ @@ -175,15 +218,40 @@ export function runRESTRequest$( requestHeaders = [...tab.value.document.request.headers] } + const finalRequestVariables = + tab.value.document.request.requestVariables.map( + (v: HoppRESTRequestVariable) => { + if (v.active) { + return { + key: v.key, + value: v.value, + secret: false, + } + } + return [] + } + ) + const finalRequest = { ...tab.value.document.request, auth: requestAuth ?? { authType: "none", authActive: false }, - headers: requestHeaders, + headers: requestHeaders as HoppRESTHeaders, } + const finalEnvs = { + requestVariables: finalRequestVariables as Environment["variables"], + environments: envs.right, + } + + const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables( + combineEnvVariables(finalEnvs) + ) + const effectiveRequest = getEffectiveRESTRequest(finalRequest, { + id: "env-id", + v: 1, name: "Env", - variables: combineEnvVariables(envs.right), + variables: finalEnvsWithNonEmptyValues, }) const [stream, cancelRun] = createRESTNetworkRequestStream(effectiveRequest) diff --git a/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js index f8f32895d..896fbeb08 100644 --- a/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js +++ b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js @@ -38,6 +38,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -141,6 +142,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -158,6 +160,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -181,6 +184,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -209,6 +213,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -239,6 +244,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -268,6 +274,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -291,6 +298,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -312,6 +320,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -335,6 +344,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -366,6 +376,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -430,6 +441,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -458,6 +470,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -515,6 +528,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -560,6 +574,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -607,6 +622,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -621,6 +637,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -645,6 +662,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -662,6 +680,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -685,6 +704,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -702,6 +722,7 @@ const samples = [ params: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -725,6 +746,7 @@ const samples = [ ], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -747,6 +769,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -767,6 +790,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -788,6 +812,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -808,6 +833,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, { @@ -839,6 +865,7 @@ const samples = [ headers: [], preRequestScript: "", testScript: "", + requestVariables: [], }), }, ] diff --git a/packages/hoppscotch-common/src/helpers/curl/curlparser.ts b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts index 155923eee..638680c5b 100644 --- a/packages/hoppscotch-common/src/helpers/curl/curlparser.ts +++ b/packages/hoppscotch-common/src/helpers/curl/curlparser.ts @@ -181,5 +181,6 @@ export const parseCurlCommand = (curlCommand: string) => { testScript: defaultRESTReq.testScript, auth, body: finalBody, + requestVariables: defaultRESTReq.requestVariables, }) } diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts index 9a1ab8a3a..04b65dd28 100644 --- a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts @@ -13,7 +13,7 @@ import { StreamSubscriberFunc } from "@composables/stream" import { AggregateEnvironment, aggregateEnvsWithSecrets$, - getAggregateEnvsWithSecrets, + getAggregateEnvs, getCurrentEnvironment, getSelectedEnvironmentType, } from "~/newstore/environments" @@ -23,15 +23,45 @@ import IconUsers from "~icons/lucide/users?raw" import IconEdit from "~icons/lucide/edit?raw" import { SecretEnvironmentService } from "~/services/secret-environment.service" import { getService } from "~/modules/dioc" +import { RESTTabService } from "~/services/tab/rest" const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g const HOPP_ENV_HIGHLIGHT = "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" -const HOPP_ENV_HIGHLIGHT_FOUND = "env-found" -const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "env-not-found" + +const HOPP_REQUEST_VARIABLE_HIGHLIGHT = "request-variable-highlight" +const HOPP_ENVIRONMENT_HIGHLIGHT = "environment-variable-highlight" +const HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT = "global-variable-highlight" +const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "environment-not-found-highlight" const secretEnvironmentService = getService(SecretEnvironmentService) +const restTabs = getService(RESTTabService) + +/** + * Transforms the environment list to a list with unique keys with value + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ +const filterNonEmptyEnvironmentVariables = ( + envs: AggregateEnvironment[] +): AggregateEnvironment[] => { + const envsMap = new Map() + + envs.forEach((env) => { + if (envsMap.has(env.key)) { + const existingEnv = envsMap.get(env.key) + + if (existingEnv?.value === "" && env.value !== "") { + envsMap.set(env.key, env) + } + } else { + envsMap.set(env.key, env) + } + }) + + return Array.from(envsMap.values()) +} const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => hoverTooltip( @@ -67,7 +97,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const parsedEnvKey = text.slice(start - from, end - from) - const tooltipEnv = aggregateEnvs.find((env) => env.key === parsedEnvKey) + const envsWithNoEmptyValues = + filterNonEmptyEnvironmentVariables(aggregateEnvs) + + const tooltipEnv = envsWithNoEmptyValues.find( + (env) => env.key === parsedEnvKey + ) const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment" @@ -95,7 +130,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const result = parseTemplateStringE(envValue, aggregateEnvs) - const finalEnv = E.isLeft(result) ? "error" : result.right + let finalEnv = E.isLeft(result) ? "error" : result.right + + // If the request variable has an secret variable + // parseTemplateStringE is passed the secret value which has value undefined + // So, we need to check if the result is undefined and then set the finalEnv to ****** + if (finalEnv === "undefined") finalEnv = "******" const selectedEnvType = getSelectedEnvironmentType() @@ -123,11 +163,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => invokeActionType = "modals.my.environment.edit" } - invokeAction(invokeActionType, { - envName: tooltipEnv?.sourceEnv !== "Global" ? envName : "Global", - variableName: parsedEnvKey, - isSecret: tooltipEnv?.secret, - }) + if (tooltipEnv?.sourceEnv === "RequestVariable") { + restTabs.currentActiveTab.value.document.optionTabPreference = + "requestVariables" + } else { + invokeAction(invokeActionType, { + envName: tooltipEnv?.sourceEnv === "Global" ? "Global" : envName, + variableName: parsedEnvKey, + isSecret: tooltipEnv?.secret, + }) + } }) editIcon.innerHTML = `${IconEdit}` tooltip.appendChild(editIcon) @@ -165,11 +210,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => ) function checkEnv(env: string, aggregateEnvs: AggregateEnvironment[]) { - const className = aggregateEnvs.find( + let className = HOPP_ENV_HIGHLIGHT_NOT_FOUND + + const envSource = aggregateEnvs.find( (k: { key: string }) => k.key === env.slice(2, -2) - ) - ? HOPP_ENV_HIGHLIGHT_FOUND - : HOPP_ENV_HIGHLIGHT_NOT_FOUND + )?.sourceEnv + + if (envSource === "RequestVariable") + className = HOPP_REQUEST_VARIABLE_HIGHLIGHT + else if (envSource === "Global") className = HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT + else if (envSource !== undefined) className = HOPP_ENVIRONMENT_HIGHLIGHT return Decoration.mark({ class: `${HOPP_ENV_HIGHLIGHT} ${className}`, @@ -185,7 +235,10 @@ const getMatchDecorator = (aggregateEnvs: AggregateEnvironment[]) => export const environmentHighlightStyle = ( aggregateEnvs: AggregateEnvironment[] ) => { - const decorator = getMatchDecorator(aggregateEnvs) + const envsWithNoEmptyValues = + filterNonEmptyEnvironmentVariables(aggregateEnvs) + + const decorator = getMatchDecorator(envsWithNoEmptyValues) return ViewPlugin.define( (view) => ({ @@ -209,10 +262,45 @@ export class HoppEnvironmentPlugin { subscribeToStream: StreamSubscriberFunc, private editorView: Ref ) { - this.envs = getAggregateEnvsWithSecrets() + const aggregateEnvs = getAggregateEnvs() + const currentTab = restTabs.currentActiveTab.value + + watch( + currentTab.document.request, + (reqVariables) => { + this.envs = [ + ...reqVariables.requestVariables.map(({ key, value }) => ({ + key, + value, + sourceEnv: "RequestVariable", + secret: false, + })), + ...aggregateEnvs, + ] + + this.editorView.value?.dispatch({ + effects: this.compartment.reconfigure([ + cursorTooltipField(this.envs), + environmentHighlightStyle(this.envs), + ]), + }) + }, + { immediate: true, deep: true } + ) subscribeToStream(aggregateEnvsWithSecrets$, (envs) => { - this.envs = envs + this.envs = [ + ...currentTab.document.request.requestVariables.map( + ({ key, value }) => ({ + key, + value, + sourceEnv: "RequestVariable", + secret: false, + }) + ), + ...envs, + ] + this.editorView.value?.dispatch({ effects: this.compartment.reconfigure([ cursorTooltipField(this.envs), @@ -251,7 +339,7 @@ export class HoppReactiveEnvPlugin { ]), }) }, - { immediate: true } + { immediate: true, deep: true } ) } diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts index 473afb9f9..5cbd4b169 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts @@ -8,6 +8,7 @@ import { knownContentTypes, makeCollection, makeRESTRequest, + HoppRESTRequestVariable, } from "@hoppscotch/data" import * as A from "fp-ts/Array" @@ -28,14 +29,27 @@ type UnwrapPromise> = T extends Promise type InsomniaDoc = UnwrapPromise> type InsomniaResource = ImportRequest +// insomnia-importers v3.6.0 doesn't provide a type for path parameters and they have deprecated the library +type InsomniaPathParameter = { + name: string + value: string +} + type InsomniaFolderResource = ImportRequest & { _type: "request_group" } -type InsomniaRequestResource = ImportRequest & { _type: "request" } +type InsomniaRequestResource = ImportRequest & { + _type: "request" +} & { + pathParameters?: InsomniaPathParameter[] +} const parseInsomniaDoc = (content: string) => TO.tryCatch(() => convert(content)) +const replacePathVarTemplating = (expression: string) => + expression.replaceAll(/:([^/]+)/g, "<<$1>>") + const replaceVarTemplating = (expression: string) => - replaceInsomniaTemplating(expression) + pipe(expression, replacePathVarTemplating, replaceInsomniaTemplating) const getFoldersIn = ( folder: InsomniaFolderResource | null, @@ -177,6 +191,15 @@ const getHoppReqParams = (req: InsomniaRequestResource): HoppRESTParam[] => active: !(param.disabled ?? false), })) ?? [] +const getHoppReqVariables = ( + req: InsomniaRequestResource +): HoppRESTRequestVariable[] => + req.pathParameters?.map((variable) => ({ + key: replaceVarTemplating(variable.name), + value: replaceVarTemplating(variable.value ?? ""), + active: true, + })) ?? [] + const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest => makeRESTRequest({ name: req.name ?? "Untitled Request", @@ -189,6 +212,8 @@ const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest => preRequestScript: "", testScript: "", + + requestVariables: getHoppReqVariables(req), }) const getHoppFolder = ( diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts index 5cf267001..14748a56c 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts @@ -16,6 +16,7 @@ import { makeRESTRequest, HoppCollection, makeCollection, + HoppRESTRequestVariable, } from "@hoppscotch/data" import { pipe, flow } from "fp-ts/function" import * as A from "fp-ts/Array" @@ -82,6 +83,27 @@ const parseOpenAPIParams = (params: OpenAPIParamsType[]): HoppRESTParam[] => ) ) +const parseOpenAPIVariables = ( + variables: OpenAPIParamsType[] +): HoppRESTRequestVariable[] => + pipe( + variables, + + A.filterMap( + flow( + O.fromPredicate((param) => param.in === "path"), + O.map( + (param) => + { + key: param.name, + value: "", // TODO: Can we do anything more ? (parse default values maybe) + active: true, + } + ) + ) + ) + ) + const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] => pipe( params, @@ -577,6 +599,10 @@ const convertPathToHoppReqs = ( preRequestScript: "", testScript: "", + + requestVariables: parseOpenAPIVariables( + (info.parameters as OpenAPIParamsType[] | undefined) ?? [] + ), }) }), diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts index bb9603dc0..c9c9d8eb3 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -5,6 +5,7 @@ import { QueryParam, RequestAuthDefinition, VariableDefinition, + Variable, } from "postman-collection" import { HoppRESTAuth, @@ -18,6 +19,7 @@ import { ValidContentTypes, knownContentTypes, FormDataKeyValue, + HoppRESTRequestVariable, } from "@hoppscotch/data" import { pipe, flow } from "fp-ts/function" import * as S from "fp-ts/string" @@ -91,6 +93,25 @@ const getHoppReqParams = (item: Item): HoppRESTParam[] => { ) } +const getHoppReqVariables = (item: Item) => { + return pipe( + item.request.url.variables.all(), + A.filter( + (variable): variable is Variable => + variable.key !== undefined && + variable.key !== null && + variable.key.length > 0 + ), + A.map((variable) => { + return { + key: replacePMVarTemplating(variable.key ?? ""), + value: replacePMVarTemplating(variable.value ?? ""), + active: !variable.disabled, + } + }) + ) +} + type PMRequestAuthDef< AuthType extends RequestAuthDefinition["type"] = RequestAuthDefinition["type"], @@ -280,6 +301,7 @@ const getHoppRequest = (item: Item): HoppRESTRequest => { params: getHoppReqParams(item), auth: getHoppReqAuth(item), body: getHoppReqBody(item), + requestVariables: getHoppReqVariables(item), // TODO: Decide about this preRequestScript: "", diff --git a/packages/hoppscotch-common/src/helpers/preRequest.ts b/packages/hoppscotch-common/src/helpers/preRequest.ts index 562ffaefc..611536c5b 100644 --- a/packages/hoppscotch-common/src/helpers/preRequest.ts +++ b/packages/hoppscotch-common/src/helpers/preRequest.ts @@ -14,8 +14,8 @@ import { SecretEnvironmentService } from "~/services/secret-environment.service" const secretEnvironmentService = getService(SecretEnvironmentService) const unsecretEnvironments = ( - global: Environment["variables"], - selected: Environment + selected: Environment, + global: Environment["variables"] ) => { const resolvedGlobalWithSecrets = global.map((globalVar, index) => { const secretVar = secretEnvironmentService.getSecretEnvironmentVariable( @@ -65,8 +65,8 @@ const unsecretEnvironments = ( export const getCombinedEnvVariables = () => { const reformedVars = unsecretEnvironments( - getGlobalVariables(), - getCurrentEnvironment() + getCurrentEnvironment(), + getGlobalVariables() ) return { global: cloneDeep(reformedVars.global), diff --git a/packages/hoppscotch-common/src/helpers/rest/default.ts b/packages/hoppscotch-common/src/helpers/rest/default.ts index d5bd45e9f..b6304d803 100644 --- a/packages/hoppscotch-common/src/helpers/rest/default.ts +++ b/packages/hoppscotch-common/src/helpers/rest/default.ts @@ -17,4 +17,5 @@ export const getDefaultRESTRequest = (): HoppRESTRequest => ({ contentType: null, body: null, }, + requestVariables: [], }) diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index d287976ee..f4adbcfff 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -22,7 +22,6 @@ import { import { arrayFlatMap, arraySort } from "../functional/array" import { toFormData } from "../functional/formData" import { tupleWithSameKeysToRecord } from "../functional/record" -import { getGlobalVariables } from "~/newstore/environments" export interface EffectiveHoppRESTRequest extends HoppRESTRequest { /** @@ -34,6 +33,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest { effectiveFinalHeaders: { key: string; value: string }[] effectiveFinalParams: { key: string; value: string }[] effectiveFinalBody: FormData | string | null + effectiveFinalRequestVariables: { key: string; value: string }[] } /** @@ -313,38 +313,53 @@ export function getEffectiveRESTRequest( request: HoppRESTRequest, environment: Environment ): EffectiveHoppRESTRequest { - const envVariables = [...environment.variables, ...getGlobalVariables()] - const effectiveFinalHeaders = pipe( - getComputedHeaders(request, envVariables).map((h) => h.header), + getComputedHeaders(request, environment.variables).map((h) => h.header), A.concat(request.headers), A.filter((x) => x.active && x.key !== ""), A.map((x) => ({ active: true, - key: parseTemplateString(x.key, envVariables), - value: parseTemplateString(x.value, envVariables), + key: parseTemplateString(x.key, environment.variables), + value: parseTemplateString(x.value, environment.variables), })) ) const effectiveFinalParams = pipe( - getComputedParams(request, envVariables).map((p) => p.param), + getComputedParams(request, environment.variables).map((p) => p.param), A.concat(request.params), A.filter((x) => x.active && x.key !== ""), A.map((x) => ({ active: true, - key: parseTemplateString(x.key, envVariables), - value: parseTemplateString(x.value, envVariables), + key: parseTemplateString(x.key, environment.variables), + value: parseTemplateString(x.value, environment.variables), })) ) - const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables) + const effectiveFinalRequestVariables = pipe( + request.requestVariables, + A.filter((x) => x.active && x.key !== ""), + A.map((x) => ({ + active: true, + key: parseTemplateString(x.key, environment.variables), + value: parseTemplateString(x.value, environment.variables), + })) + ) + + const effectiveFinalBody = getFinalBodyFromRequest( + request, + environment.variables + ) return { ...request, - effectiveFinalURL: parseTemplateString(request.endpoint, envVariables), + effectiveFinalURL: parseTemplateString( + request.endpoint, + environment.variables + ), effectiveFinalHeaders, effectiveFinalParams, effectiveFinalBody, + effectiveFinalRequestVariables, } } diff --git a/packages/hoppscotch-common/src/newstore/history.ts b/packages/hoppscotch-common/src/newstore/history.ts index 880c12382..b47c054ea 100644 --- a/packages/hoppscotch-common/src/newstore/history.ts +++ b/packages/hoppscotch-common/src/newstore/history.ts @@ -353,6 +353,7 @@ executedResponses$.subscribe((res) => { params: res.req.params, preRequestScript: res.req.preRequestScript, testScript: res.req.testScript, + requestVariables: res.req.requestVariables, v: res.req.v, }, responseMeta: { diff --git a/packages/hoppscotch-common/src/newstore/settings.ts b/packages/hoppscotch-common/src/newstore/settings.ts index eb5249bf6..61ed992f8 100644 --- a/packages/hoppscotch-common/src/newstore/settings.ts +++ b/packages/hoppscotch-common/src/newstore/settings.ts @@ -38,6 +38,7 @@ export type SettingsDef = { httpUrlEncoded: boolean httpPreRequest: boolean httpTest: boolean + httpRequestVariables: boolean graphqlQuery: boolean graphqlResponseBody: boolean graphqlHeaders: boolean @@ -79,6 +80,7 @@ export const getDefaultSettings = (): SettingsDef => ({ httpUrlEncoded: true, httpPreRequest: true, httpTest: true, + httpRequestVariables: true, graphqlQuery: true, graphqlResponseBody: true, graphqlHeaders: false, diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts index e26b57c0d..89a603966 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/environment.inspector.ts @@ -10,6 +10,7 @@ import { Ref, markRaw } from "vue" import IconPlusCircle from "~icons/lucide/plus-circle" import { HoppRESTRequest } from "@hoppscotch/data" import { + AggregateEnvironment, aggregateEnvsWithSecrets$, getCurrentEnvironment, getSelectedEnvironmentType, @@ -18,6 +19,7 @@ import { invokeAction } from "~/helpers/actions" import { computed } from "vue" import { useStreamStatic } from "~/composables/stream" import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { RESTTabService } from "~/services/tab/rest" const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g @@ -41,6 +43,7 @@ export class EnvironmentInspectorService extends Service implements Inspector { private readonly inspection = this.bind(InspectionService) private readonly secretEnvs = this.bind(SecretEnvironmentService) + private readonly restTabs = this.bind(RESTTabService) private aggregateEnvsWithSecrets = useStreamStatic( aggregateEnvsWithSecrets$, @@ -68,7 +71,14 @@ export class EnvironmentInspectorService extends Service implements Inspector { ) => { const newErrors: InspectorResult[] = [] - const envKeys = this.aggregateEnvsWithSecrets.value.map((e) => e.key) + const currentTab = this.restTabs.currentActiveTab.value + + const environmentVariables = [ + ...currentTab.document.request.requestVariables, + ...this.aggregateEnvsWithSecrets.value, + ] + + const envKeys = environmentVariables.map((e) => e.key) target.forEach((element, index) => { if (isENVInString(element)) { @@ -124,6 +134,31 @@ export class EnvironmentInspectorService extends Service implements Inspector { return newErrors } + /** + * Transforms the environment list to a list with unique keys with value + * @param envs The environment list to be transformed + * @returns The transformed environment list with keys with value + */ + private filterNonEmptyEnvironmentVariables = ( + envs: AggregateEnvironment[] + ): AggregateEnvironment[] => { + const envsMap = new Map() + + envs.forEach((env) => { + if (envsMap.has(env.key)) { + const existingEnv = envsMap.get(env.key) + + if (existingEnv?.value === "" && env.value !== "") { + envsMap.set(env.key, env) + } + } else { + envsMap.set(env.key, env) + } + }) + + return Array.from(envsMap.values()) + } + /** * Checks if the environment variables in the target array are empty * @param target The target array to validate @@ -145,7 +180,19 @@ export class EnvironmentInspectorService extends Service implements Inspector { const formattedExEnv = exEnv.slice(2, -2) const currentSelectedEnvironment = getCurrentEnvironment() - this.aggregateEnvsWithSecrets.value.forEach((env) => { + const currentTab = this.restTabs.currentActiveTab.value + + const environmentVariables = + this.filterNonEmptyEnvironmentVariables([ + ...currentTab.document.request.requestVariables.map((env) => ({ + ...env, + secret: false, + sourceEnv: "RequestVariable", + })), + ...this.aggregateEnvsWithSecrets.value, + ]) + + environmentVariables.forEach((env) => { const hasSecretEnv = this.secretEnvs.hasSecretValue( env.sourceEnv !== "Global" ? currentSelectedEnvironment.id @@ -199,14 +246,19 @@ export class EnvironmentInspectorService extends Service implements Inspector { "inspections.environment.add_environment_value" ), apply: () => { - invokeAction(invokeActionType, { - envName: - env.sourceEnv !== "Global" - ? currentSelectedEnvironment.name - : "Global", - variableName: formattedExEnv, - isSecret: env.secret, - }) + if (env.sourceEnv === "RequestVariable") { + currentTab.document.optionTabPreference = + "requestVariables" + } else { + invokeAction(invokeActionType, { + envName: + env.sourceEnv === "Global" + ? "Global" + : currentSelectedEnvironment.name, + variableName: formattedExEnv, + isSecret: env.secret, + }) + } }, }, severity: 2, diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index 02385fe9f..050237b2b 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -25,7 +25,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ folders: [], requests: [ { - v: "1", + v: "2", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], @@ -35,6 +35,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ preRequestScript: "", testScript: "", body: { contentType: null, body: null }, + requestVariables: [], }, ], auth: { authType: "none", authActive: true }, @@ -136,7 +137,8 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [ params: [], preRequestScript: "", testScript: "", - v: "1", + requestVariables: [], + v: "2", }, responseMeta: { duration: 807, statusCode: 200 }, star: false, @@ -192,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa", doc: { request: { - v: "1", + v: "2", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], @@ -202,6 +204,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { preRequestScript: "", testScript: "", body: { contentType: null, body: null }, + requestVariables: [], }, isDirty: false, saveContext: { diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index bb4c03799..640604286 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -48,21 +48,22 @@ const SettingsDefSchema = z.object({ WRAP_LINES: z.optional( z.object({ - httpRequestBody: z.boolean(), - httpResponseBody: z.boolean(), - httpHeaders: z.boolean(), - httpParams: z.boolean(), - httpUrlEncoded: z.boolean(), - httpPreRequest: z.boolean(), - httpTest: z.boolean(), - graphqlQuery: z.boolean(), - graphqlResponseBody: z.boolean(), - graphqlHeaders: z.boolean(), - graphqlVariables: z.boolean(), - graphqlSchema: z.boolean(), - importCurl: z.boolean(), - codeGen: z.boolean(), - cookie: z.boolean(), + httpRequestBody: z.boolean().catch(true), + httpResponseBody: z.boolean().catch(true), + httpHeaders: z.boolean().catch(true), + httpParams: z.boolean().catch(true), + httpUrlEncoded: z.boolean().catch(true), + httpPreRequest: z.boolean().catch(true), + httpTest: z.boolean().catch(true), + httpRequestVariables: z.boolean().catch(true), + graphqlQuery: z.boolean().catch(true), + graphqlResponseBody: z.boolean().catch(true), + graphqlHeaders: z.boolean().catch(false), + graphqlVariables: z.boolean().catch(false), + graphqlSchema: z.boolean().catch(true), + importCurl: z.boolean().catch(true), + codeGen: z.boolean().catch(true), + cookie: z.boolean().catch(true), }) ), }) @@ -514,6 +515,7 @@ const validRestOperations = [ "authorization", "preRequestScript", "tests", + "requestVariables", ] as const export const REST_TAB_STATE_SCHEMA = z diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index c0be37354..72390ec5b 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -3,6 +3,7 @@ import * as S from "fp-ts/string" import cloneDeep from "lodash/cloneDeep" import V0_VERSION from "./v/0" import V1_VERSION from "./v/1" +import V2_VERSION from "./v/2" import { createVersionedEntity, InferredEntity } from "verzod" import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq" import { @@ -11,6 +12,7 @@ import { HoppRESTHeaders, HoppRESTParams, } from "./v/1" +import { HoppRESTRequestVariables } from "./v/2" import { z } from "zod" export * from "./content-types" @@ -28,16 +30,19 @@ export { HoppRESTHeaders, } from "./v/1" +export { HoppRESTRequestVariables } from "./v/2" + const versionedObject = z.object({ // v is a stringified number v: z.string().regex(/^\d+$/).transform(Number), }) export const HoppRESTRequest = createVersionedEntity({ - latestVersion: 1, + latestVersion: 2, versionMap: { 0: V0_VERSION, 1: V1_VERSION, + 2: V2_VERSION, }, getVersion(data) { // For V1 onwards we have the v string storing the number @@ -73,12 +78,18 @@ const HoppRESTRequestEq = Eq.struct({ name: S.Eq, preRequestScript: S.Eq, testScript: S.Eq, + requestVariables: mapThenEq( + (arr) => arr.filter((v: any) => v.key !== "" && v.value !== ""), + lodashIsEqualEq + ), }) -export const RESTReqSchemaVersion = "1" +export const RESTReqSchemaVersion = "2" export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number] +export type HoppRESTRequestVariable = + HoppRESTRequest["requestVariables"][number] export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals @@ -144,6 +155,14 @@ export function safelyExtractRESTRequest( req.headers = result.data } } + + if ("requestVariables" in x) { + const result = HoppRESTRequestVariables.safeParse(x.requestVariables) + + if (result.success) { + req.requestVariables = result.data + } + } } return req @@ -160,7 +179,7 @@ export function makeRESTRequest( export function getDefaultRESTRequest(): HoppRESTRequest { return { - v: "1", + v: "2", endpoint: "https://echo.hoppscotch.io", name: "Untitled", params: [], @@ -176,6 +195,7 @@ export function getDefaultRESTRequest(): HoppRESTRequest { contentType: null, body: null, }, + requestVariables: [], } } diff --git a/packages/hoppscotch-data/src/rest/v/1.ts b/packages/hoppscotch-data/src/rest/v/1.ts index 346717754..66a13da41 100644 --- a/packages/hoppscotch-data/src/rest/v/1.ts +++ b/packages/hoppscotch-data/src/rest/v/1.ts @@ -141,7 +141,7 @@ export const HoppRESTHeaders = z.array( export type HoppRESTHeaders = z.infer -const V1_SCHEMA = z.object({ +export const V1_SCHEMA = z.object({ v: z.literal("1"), id: z.optional(z.string()), // Firebase Firestore ID @@ -158,7 +158,7 @@ const V1_SCHEMA = z.object({ body: HoppRESTReqBody, }) -function parseRequestBody( +export function parseRequestBody( x: z.infer ): z.infer["body"] { return { diff --git a/packages/hoppscotch-data/src/rest/v/2.ts b/packages/hoppscotch-data/src/rest/v/2.ts new file mode 100644 index 000000000..6c257e2ca --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/2.ts @@ -0,0 +1,50 @@ +import { defineVersion } from "verzod" +import { z } from "zod" +import { + HoppRESTAuth, + HoppRESTHeaders, + HoppRESTParams, + HoppRESTReqBody, +} from "./1" +import { V1_SCHEMA } from "./1" + +export const HoppRESTRequestVariables = z.array( + z.object({ + key: z.string().catch(""), + value: z.string().catch(""), + active: z.boolean().catch(true), + }) +) + +export type HoppRESTRequestVariables = z.infer + +const V2_SCHEMA = z.object({ + v: z.literal("2"), + id: z.optional(z.string()), // Firebase Firestore ID + + name: z.string(), + method: z.string(), + endpoint: z.string(), + params: HoppRESTParams, + headers: HoppRESTHeaders, + preRequestScript: z.string().catch(""), + testScript: z.string().catch(""), + + auth: HoppRESTAuth, + + body: HoppRESTReqBody, + + requestVariables: HoppRESTRequestVariables, +}) + +export default defineVersion({ + initial: false, + schema: V2_SCHEMA, + up(old: z.infer) { + return { + ...old, + v: "2", + requestVariables: [], + } as z.infer + }, +}) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts index 234d45e57..67c6c39e7 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/collections.platform.ts @@ -133,6 +133,7 @@ function exportedCollectionToHoppCollection( params, preRequestScript, testScript, + requestVariables, }) => ({ id, v, @@ -145,6 +146,7 @@ function exportedCollectionToHoppCollection( params, preRequestScript, testScript, + requestVariables, }) ), auth: data.auth,