diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 00000000000..3e2028f7d8f --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,48 @@ +# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS! +# Internal Docker Compose Image used for internal testing deployments + +version: "3.7" + +services: + hoppscotch-db: + image: postgres:15 + user: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: testpass + POSTGRES_DB: hoppscotch + healthcheck: + test: + [ + "CMD-SHELL", + "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'" + ] + interval: 5s + timeout: 5s + retries: 10 + + hoppscotch-aio: + container_name: hoppscotch-aio + build: + dockerfile: prod.Dockerfile + context: . + target: aio + environment: + - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch + - ENABLE_SUBPATH_BASED_ACCESS=true + env_file: + - ./.env + depends_on: + hoppscotch-db: + condition: service_healthy + command: ["sh", "-c", "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs"] + healthcheck: + test: + - CMD + - curl + - '-f' + - 'http://localhost:80' + interval: 2s + timeout: 10s + retries: 30 + diff --git a/healthcheck.sh b/healthcheck.sh index 87404aeb81e..c772d6bf555 100644 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -9,6 +9,10 @@ curlCheck() { fi } -curlCheck "http://localhost:3000" -curlCheck "http://localhost:3100" -curlCheck "http://localhost:3170/ping" +if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then + curlCheck "http://localhost:80/backend/ping" +else + curlCheck "http://localhost:3000" + curlCheck "http://localhost:3100" + curlCheck "http://localhost:3170/ping" +fi diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 68a5794859d..208a8046406 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -84,6 +84,12 @@ export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const; */ export const USER_UPDATE_FAILED = 'user/update_failed' as const; +/** + * User display name validation failure + * (UserService) + */ +export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const; + /** * User deletion failure * (UserService) @@ -750,3 +756,8 @@ export const DATABASE_TABLE_NOT_EXIST = * (InfraConfigService) */ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized'; + +/** + * Inputs supplied are invalid + */ +export const INVALID_PARAMS = 'invalid_parameters' as const; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts index 4da0eec9c6b..dfeb9d6d045 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + HttpStatus, + Param, + Query, + UseGuards, +} from '@nestjs/common'; import { TeamCollectionService } from './team-collection.service'; import * as E from 'fp-ts/Either'; import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; @@ -7,13 +14,15 @@ import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorat import { TeamMemberRole } from '@prisma/client'; import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard'; import { throwHTTPErr } from 'src/utils'; +import { RESTError } from 'src/types/RESTError'; +import { INVALID_PARAMS } from 'src/errors'; @UseGuards(ThrottlerBehindProxyGuard) @Controller({ path: 'team-collection', version: '1' }) export class TeamCollectionController { constructor(private readonly teamCollectionService: TeamCollectionService) {} - @Get('search/:teamID/:searchQuery') + @Get('search/:teamID') @RequiresTeamRole( TeamMemberRole.VIEWER, TeamMemberRole.EDITOR, @@ -21,13 +30,20 @@ export class TeamCollectionController { ) @UseGuards(JwtAuthGuard, RESTTeamMemberGuard) async searchByTitle( - @Param('searchQuery') searchQuery: string, + @Query('searchQuery') searchQuery: string, @Param('teamID') teamID: string, @Query('take') take: string, @Query('skip') skip: string, ) { + if (!teamID || !searchQuery) { + return { + message: INVALID_PARAMS, + statusCode: HttpStatus.BAD_REQUEST, + }; + } + const res = await this.teamCollectionService.searchByTitle( - searchQuery, + searchQuery.trim(), teamID, parseInt(take), parseInt(skip), diff --git a/packages/hoppscotch-backend/src/user/user.resolver.ts b/packages/hoppscotch-backend/src/user/user.resolver.ts index 080592c8156..da83e8625dd 100644 --- a/packages/hoppscotch-backend/src/user/user.resolver.ts +++ b/packages/hoppscotch-backend/src/user/user.resolver.ts @@ -58,6 +58,29 @@ export class UserResolver { if (E.isLeft(updatedUser)) throwErr(updatedUser.left); return updatedUser.right; } + + @Mutation(() => User, { + description: 'Update a users display name', + }) + @UseGuards(GqlAuthGuard) + async updateDisplayName( + @GqlUser() user: AuthUser, + @Args({ + name: 'updatedDisplayName', + description: 'New name of user', + type: () => String, + }) + updatedDisplayName: string, + ) { + const updatedUser = await this.userService.updateUserDisplayName( + user.uid, + updatedDisplayName, + ); + + if (E.isLeft(updatedUser)) throwErr(updatedUser.left); + return updatedUser.right; + } + @Mutation(() => Boolean, { description: 'Delete an user account', }) diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts index 21e2736bcbf..b5093831c86 100644 --- a/packages/hoppscotch-backend/src/user/user.service.spec.ts +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -1,4 +1,9 @@ -import { JSON_INVALID, USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors'; +import { + JSON_INVALID, + USERS_NOT_FOUND, + USER_NOT_FOUND, + USER_SHORT_DISPLAY_NAME, +} from 'src/errors'; import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from 'src/prisma/prisma.service'; import { AuthUser } from 'src/types/AuthUser'; @@ -480,6 +485,14 @@ describe('UserService', () => { ); expect(result).toEqualLeft(USER_NOT_FOUND); }); + test('should resolve left and error when short display name is passed', async () => { + const newDisplayName = ''; + const result = await userService.updateUserDisplayName( + user.uid, + newDisplayName, + ); + expect(result).toEqualLeft(USER_SHORT_DISPLAY_NAME); + }); }); describe('fetchAllUsers', () => { diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index 59d17a089cc..26018894bf6 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -8,7 +8,11 @@ import * as T from 'fp-ts/Task'; import * as A from 'fp-ts/Array'; import { pipe, constVoid } from 'fp-ts/function'; import { AuthUser } from 'src/types/AuthUser'; -import { USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors'; +import { + USERS_NOT_FOUND, + USER_NOT_FOUND, + USER_SHORT_DISPLAY_NAME, +} from 'src/errors'; import { SessionType, User } from './user.model'; import { USER_UPDATE_FAILED } from 'src/errors'; import { PubSubService } from 'src/pubsub/pubsub.service'; @@ -291,6 +295,10 @@ export class UserService { * @returns a Either of User or error */ async updateUserDisplayName(userUID: string, displayName: string) { + if (!displayName || displayName.length === 0) { + return E.left(USER_SHORT_DISPLAY_NAME); + } + try { const dbUpdatedUser = await this.prisma.user.update({ where: { uid: userUID }, diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 34095eb638f..f201a37b3ea 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -261,29 +261,28 @@ export function checkEnvironmentAuthProvider( * Source: https://stackoverflow.com/a/32648526 */ export function escapeSqlLikeString(str: string) { - if (typeof str != 'string') - return str; + if (typeof str != 'string') return str; - return str.replace(/[\0\x08\x09\x1a\n\r"'\\\%]/g, function (char) { - switch (char) { - case "\0": - return "\\0"; - case "\x08": - return "\\b"; - case "\x09": - return "\\t"; - case "\x1a": - return "\\z"; - case "\n": - return "\\n"; - case "\r": - return "\\r"; - case "\"": - case "'": - case "\\": - case "%": - return "\\"+char; // prepends a backslash to backslash, percent, - // and double/single quotes - } - }); + return str.replace(/[\0\x08\x09\x1a\n\r"'\\\%]/g, function (char) { + switch (char) { + case '\0': + return '\\0'; + case '\x08': + return '\\b'; + case '\x09': + return '\\t'; + case '\x1a': + return '\\z'; + case '\n': + return '\\n'; + case '\r': + return '\\r'; + case '"': + case "'": + case '\\': + case '%': + return '\\' + char; // prepends a backslash to backslash, percent, + // and double/single quotes + } + }); } diff --git a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts index 675e317509c..25b3325d244 100644 --- a/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/commands/test.spec.ts @@ -20,7 +20,7 @@ describe("Test `hopp test ` command:", () => { const out = getErrorCode(stderr); expect(out).toBe("INVALID_ARGUMENT"); }); - }) + }); describe("Supplied collection export file validations", () => { test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => { @@ -66,6 +66,43 @@ describe("Test `hopp test ` command:", () => { }); }); + describe("Versioned entities", () => { + describe("Collections & Requests", () => { + const testFixtures = [ + { fileName: "coll-v1-req-v0.json", collVersion: 1, reqVersion: 0 }, + { fileName: "coll-v1-req-v1.json", collVersion: 1, reqVersion: 1 }, + { fileName: "coll-v2-req-v2.json", collVersion: 2, reqVersion: 2 }, + { fileName: "coll-v2-req-v3.json", collVersion: 2, reqVersion: 3 }, + ]; + + testFixtures.forEach(({ collVersion, fileName, reqVersion }) => { + test(`Successfully processes a supplied collection export file where the collection is based on the "v${collVersion}" schema and the request following the "v${reqVersion}" schema`, async () => { + const args = `test ${getTestJsonFilePath(fileName, "collection")}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }); + }); + }); + + describe("Environments", () => { + const testFixtures = [ + { fileName: "env-v0.json", version: 0 }, + { fileName: "env-v1.json", version: 1 }, + ]; + + testFixtures.forEach(({ fileName, version }) => { + test(`Successfully processes the supplied collection and environment export files where the environment is based on the "v${version}" schema`, async () => { + const ENV_PATH = getTestJsonFilePath(fileName, "environment"); + const args = `test ${getTestJsonFilePath("sample-coll.json", "collection")} --env ${ENV_PATH}`; + const { error } = await runCLI(args); + + expect(error).toBeNull(); + }); + }); + }); + }); + test("Successfully processes a supplied collection export file of the expected format", async () => { const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`; const { error } = await runCLI(args); @@ -75,7 +112,8 @@ describe("Test `hopp test ` command:", () => { test("Successfully inherits headers and authorization set at the root collection", async () => { const args = `test ${getTestJsonFilePath( - "collection-level-headers-auth-coll.json", "collection" + "collection-level-headers-auth-coll.json", + "collection" )}`; const { error } = await runCLI(args); @@ -84,7 +122,8 @@ describe("Test `hopp test ` command:", () => { test("Persists environment variables set in the pre-request script for consumption in the test script", async () => { const args = `test ${getTestJsonFilePath( - "pre-req-script-env-var-persistence-coll.json", "collection" + "pre-req-script-env-var-persistence-coll.json", + "collection" )}`; const { error } = await runCLI(args); @@ -106,7 +145,8 @@ describe("Test `hopp test --env ` command:", () => { test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => { const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath( - "notjson-coll.txt", "collection" + "notjson-coll.txt", + "collection" )}`; const { stderr } = await runCLI(args); @@ -123,7 +163,10 @@ describe("Test `hopp test --env ` command:", () => { }); test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => { - const ENV_PATH = getTestJsonFilePath("malformed-envs.json", "environment"); + const ENV_PATH = getTestJsonFilePath( + "malformed-envs.json", + "environment" + ); const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`; const { stderr } = await runCLI(args); @@ -142,7 +185,10 @@ describe("Test `hopp test --env ` command:", () => { }); test("Successfully resolves values from the supplied environment export file", async () => { - const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection"); + const TESTS_PATH = getTestJsonFilePath( + "env-flag-tests-coll.json", + "collection" + ); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); const args = `test ${TESTS_PATH} --env ${ENV_PATH}`; @@ -151,8 +197,14 @@ describe("Test `hopp test --env ` command:", () => { }); test("Successfully resolves environment variables referenced in the request body", async () => { - const COLL_PATH = getTestJsonFilePath("req-body-env-vars-coll.json", "collection"); - const ENVS_PATH = getTestJsonFilePath("req-body-env-vars-envs.json", "environment"); + const COLL_PATH = getTestJsonFilePath( + "req-body-env-vars-coll.json", + "collection" + ); + const ENVS_PATH = getTestJsonFilePath( + "req-body-env-vars-envs.json", + "environment" + ); const args = `test ${COLL_PATH} --env ${ENVS_PATH}`; const { error } = await runCLI(args); @@ -160,7 +212,10 @@ describe("Test `hopp test --env ` command:", () => { }); test("Works with shorth `-e` flag", async () => { - const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection"); + const TESTS_PATH = getTestJsonFilePath( + "env-flag-tests-coll.json", + "collection" + ); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment"); const args = `test ${TESTS_PATH} -e ${ENV_PATH}`; @@ -183,7 +238,10 @@ describe("Test `hopp test --env ` command:", () => { secretHeaderValue: "secret-header-value", }; - const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection"); + const COLL_PATH = getTestJsonFilePath( + "secret-envs-coll.json", + "collection" + ); const ENVS_PATH = getTestJsonFilePath("secret-envs.json", "environment"); const args = `test ${COLL_PATH} --env ${ENVS_PATH}`; @@ -197,8 +255,14 @@ describe("Test `hopp test --env ` command:", () => { // Prefers values specified in the environment export file over values set in the system environment test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => { - const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection"); - const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment"); + const COLL_PATH = getTestJsonFilePath( + "secret-envs-coll.json", + "collection" + ); + const ENVS_PATH = getTestJsonFilePath( + "secret-supplied-values-envs.json", + "environment" + ); const args = `test ${COLL_PATH} --env ${ENVS_PATH}`; const { error, stdout } = await runCLI(args); @@ -212,9 +276,13 @@ describe("Test `hopp test --env ` command:", () => { // Values set from the scripting context takes the highest precedence test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => { const COLL_PATH = getTestJsonFilePath( - "secret-envs-persistence-coll.json", "collection" + "secret-envs-persistence-coll.json", + "collection" + ); + const ENVS_PATH = getTestJsonFilePath( + "secret-supplied-values-envs.json", + "environment" ); - const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment"); const args = `test ${COLL_PATH} --env ${ENVS_PATH}`; const { error, stdout } = await runCLI(args); @@ -227,10 +295,12 @@ describe("Test `hopp test --env ` command:", () => { test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => { const COLL_PATH = getTestJsonFilePath( - "secret-envs-persistence-scripting-coll.json", "collection" + "secret-envs-persistence-scripting-coll.json", + "collection" ); const ENVS_PATH = getTestJsonFilePath( - "secret-envs-persistence-scripting-envs.json", "environment" + "secret-envs-persistence-scripting-envs.json", + "environment" ); const args = `test ${COLL_PATH} --env ${ENVS_PATH}`; diff --git a/packages/hoppscotch-cli/src/__tests__/functions/checks/isRESTCollection.spec.ts b/packages/hoppscotch-cli/src/__tests__/functions/checks/isRESTCollection.spec.ts deleted file mode 100644 index b9b30e9aca7..00000000000 --- a/packages/hoppscotch-cli/src/__tests__/functions/checks/isRESTCollection.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { isRESTCollection } from "../../../utils/checks"; - -describe("isRESTCollection", () => { - test("Undefined collection value.", () => { - expect(isRESTCollection(undefined)).toBeFalsy(); - }); - - test("Invalid id value.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: 1, - }) - ).toBeFalsy(); - }); - - test("Invalid requests value.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: "1", - requests: null, - }) - ).toBeFalsy(); - }); - - test("Invalid folders value.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: "1", - requests: [], - folders: undefined, - }) - ).toBeFalsy(); - }); - - test("Invalid RESTCollection(s) in folders.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: "1", - requests: [], - folders: [ - { - v: 1, - name: "test1", - id: "2", - requests: undefined, - folders: [], - }, - ], - }) - ).toBeFalsy(); - }); - - test("Invalid HoppRESTRequest(s) in requests.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: "1", - requests: [{}], - folders: [], - }) - ).toBeFalsy(); - }); - - test("Valid RESTCollection.", () => { - expect( - isRESTCollection({ - v: 1, - name: "test", - id: "1", - requests: [], - folders: [], - }) - ).toBeTruthy(); - }); -}); diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v0.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v0.json new file mode 100644 index 00000000000..6393ba6ad31 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v0.json @@ -0,0 +1,27 @@ +{ + "v": 1, + "name": "coll-v1", + "folders": [], + "requests": [ + { + "url": "https://httpbin.org", + "path": "/get", + "headers": [ + { "key": "Inactive-Header", "value": "Inactive Header", "active": false }, + { "key": "Authorization", "value": "Bearer token123", "active": true } + ], + "params": [ + { "key": "key", "value": "value", "active": true }, + { "key": "inactive-key", "value": "inactive-param", "active": false } + ], + "name": "req-v0", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})", + "contentType": "application/json", + "body": "", + "auth": "Bearer Token", + "bearerToken": "token123" + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v1.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v1.json new file mode 100644 index 00000000000..61f9adb52b5 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v1-req-v1.json @@ -0,0 +1,48 @@ +{ + "v": 1, + "name": "coll-v1", + "folders": [], + "requests": [ + { + "v": "1", + "endpoint": "https://httpbin.org/get", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v1", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + } + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v2.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v2.json new file mode 100644 index 00000000000..780373f8282 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v2.json @@ -0,0 +1,54 @@ +{ + "v": 2, + "name": "coll-v2", + "folders": [], + "requests": [ + { + "v": "2", + "endpoint": "https://httpbin.org/get", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v2", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v3.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v3.json new file mode 100644 index 00000000000..fcce134a8de --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/coll-v2-req-v3.json @@ -0,0 +1,54 @@ +{ + "v": 2, + "name": "coll-v2", + "folders": [], + "requests": [ + { + "v": "3", + "endpoint": "https://httpbin.org/get", + "headers": [ + { + "key": "Inactive-Header", + "value": "Inactive Header", + "active": false + }, + { + "key": "Authorization", + "value": "Bearer token123", + "active": true + } + ], + "params": [ + { + "key": "key", + "value": "value", + "active": true + }, + { + "key": "inactive-key", + "value": "inactive-param", + "active": false + } + ], + "name": "req-v3", + "method": "GET", + "preRequestScript": "", + "testScript": "pw.test(\"Asserts request params\", () => {\n pw.expect(pw.response.body.args.key).toBe(\"value\")\n pw.expect(pw.response.body.args[\"inactive-key\"]).toBe(undefined)\n})\n\npw.test(\"Asserts request headers\", () => {\n pw.expect(pw.response.body.headers[\"Authorization\"]).toBe(\"Bearer token123\")\n pw.expect(pw.response.body.headers[\"Inactive-Header\"]).toBe(undefined)\n})", + "body": { + "contentType": null, + "body": null + }, + "auth": { + "authType": "bearer", + "authActive": true, + "token": "token123" + }, + "requestVariables": [] + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [] +} \ No newline at end of file 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 03d2598eea5..ca986c8ae7d 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 @@ -1,23 +1,23 @@ [ { - "v": 1, + "v": 2, "name": "CollectionA", "folders": [ { - "v": 1, + "v": 2, "name": "FolderA", "folders": [ { - "v": 1, + "v": 2, "name": "FolderB", "folders": [ { - "v": 1, + "v": 2, "name": "FolderC", "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestD", "params": [], @@ -53,7 +53,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestC", "params": [], @@ -90,7 +90,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -119,7 +119,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], @@ -153,16 +153,16 @@ } }, { - "v": 1, + "v": 2, "name": "CollectionB", "folders": [ { - "v": 1, + "v": 2, "name": "FolderA", "folders": [], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestB", "params": [], @@ -191,7 +191,7 @@ ], "requests": [ { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io", "name": "RequestA", "params": [], 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 879264f7fe2..ded538ab165 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": "2", + "v": "3", "endpoint": "<>", "name": "test1", "params": [], 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 fec2e4e8987..b8172049102 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": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -13,10 +13,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npwd.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", @@ -24,10 +21,10 @@ "contentType": "application/json", "body": "{\n\"test\": \"<>\"\n}" }, - "requestVariables": [], + "requestVariables": [] }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.dio/<>", "name": "success", "params": [], @@ -35,10 +32,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.setd(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", 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 9050f523c04..a4c4672151d 100644 --- a/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/malformed-coll.json @@ -4,7 +4,7 @@ "folders": [], "requests": { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "fail", "params": [], @@ -12,10 +12,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"string\");\n});", @@ -26,7 +23,7 @@ "requestVariables": [], }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -34,10 +31,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(300);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", 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 fb448bd174f..e958164a35e 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": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "", "params": [], @@ -13,10 +13,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE1\", \"devblin_local1\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", @@ -27,7 +24,7 @@ "requestVariables": [] }, { - "v": "2", + "v": "3", "endpoint": "https://echo.hoppscotch.io/<>", "name": "success", "params": [], @@ -35,10 +32,7 @@ "method": "GET", "auth": { "authType": "none", - "authActive": true, - "addTo": "Headers", - "key": "", - "value": "" + "authActive": true }, "preRequestScript": "pw.env.set(\"HEADERS_TYPE2\", \"devblin_local2\");", "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", 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 c0197b092dd..0d625b81b86 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": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "sample-req", 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 0e5f4590b7b..0ddf3ef316e 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": "2", + "v": "3", "name": "test-request", "endpoint": "https://echo.hoppscotch.io", "method": "POST", diff --git a/packages/hoppscotch-cli/src/__tests__/samples/collections/sample-coll.json b/packages/hoppscotch-cli/src/__tests__/samples/collections/sample-coll.json new file mode 100644 index 00000000000..b0ca8cecf2f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/collections/sample-coll.json @@ -0,0 +1,26 @@ +{ + "v": 1, + "name": "tests", + "folders": [], + "requests": [ + { + "v": "2", + "endpoint": "<>", + "name": "", + "params": [], + "headers": [], + "method": "GET", + "auth": { + "authType": "none", + "authActive": true + }, + "preRequestScript": "", + "testScript": "// Check status code is 200\npw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\n// Check JSON response property\npw.test(\"Check JSON response property\", ()=> {\n pw.expect(pw.response.body.method).toBe(\"GET\");\n pw.expect(pw.response.body.headers).toBeType(\"object\");\n});", + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [] + } + ] +} \ No newline at end of file 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 57889f26b4d..2cea26e85ab 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": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-headers", @@ -23,7 +23,7 @@ "preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": "{\n \"secretBodyKey\": \"<>\"\n}", @@ -39,7 +39,7 @@ "preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-query-params", @@ -58,7 +58,7 @@ "preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "basic", "password": "<>", @@ -76,7 +76,7 @@ "preRequestScript": "" }, { - "v": "2", + "v": "3", "auth": { "token": "<>", "authType": "bearer", @@ -95,7 +95,7 @@ "preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true }, "body": { "body": null, "contentType": null }, "name": "test-secret-fallback", 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 04a32e680dd..823eec80168 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": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -29,7 +29,7 @@ "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" }, { - "v": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -54,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": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -73,7 +73,7 @@ "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": "2", + "v": "3", "auth": { "authType": "none", "authActive": true @@ -98,7 +98,7 @@ "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": "2", + "v": "3", "auth": { "authType": "basic", "password": "<>", @@ -119,7 +119,7 @@ "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": "2", + "v": "3", "auth": { "token": "<>", "authType": "bearer", 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 76ab9ea891a..61dc17cd243 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": "2", + "v": "3", "endpoint": "https://httpbin.org/post", "name": "req", "params": [], diff --git a/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v0.json b/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v0.json new file mode 100644 index 00000000000..16b232857c5 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v0.json @@ -0,0 +1,9 @@ +{ + "name": "env-v0", + "variables": [ + { + "key": "baseURL", + "value": "https://echo.hoppscotch.io" + } + ] +} \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v1.json b/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v1.json new file mode 100644 index 00000000000..4ab5aa65f74 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/samples/environments/env-v1.json @@ -0,0 +1,10 @@ +{ + "name": "env-v0", + "variables": [ + { + "key": "baseURL", + "value": "https://echo.hoppscotch.io", + "secret": false + } + ] + } \ No newline at end of file diff --git a/packages/hoppscotch-cli/src/options/test/env.ts b/packages/hoppscotch-cli/src/options/test/env.ts index 6e1c45271c7..2cec231a1de 100644 --- a/packages/hoppscotch-cli/src/options/test/env.ts +++ b/packages/hoppscotch-cli/src/options/test/env.ts @@ -6,7 +6,7 @@ import { error } from "../../types/errors"; import { HoppEnvKeyPairObject, HoppEnvPair, - HoppEnvs + HoppEnvs, } from "../../types/request"; import { readJsonFile } from "../../utils/mutators"; @@ -17,7 +17,7 @@ import { readJsonFile } from "../../utils/mutators"; */ export async function parseEnvsData(path: string) { const contents = await readJsonFile(path); - const envPairs: Array = []; + const envPairs: Array> = []; // The legacy key-value pair format that is still supported const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents); @@ -26,7 +26,9 @@ export async function parseEnvsData(path: string) { const HoppEnvExportObjectResult = Environment.safeParse(contents); // Shape of the bulk environment export object that is exported from the app - const HoppBulkEnvExportObjectResult = z.array(entityReference(Environment)).safeParse(contents) + const HoppBulkEnvExportObjectResult = z + .array(entityReference(Environment)) + .safeParse(contents); // CLI doesnt support bulk environments export // Hence we check for this case and throw an error if it matches the format @@ -36,13 +38,16 @@ export async function parseEnvsData(path: string) { // Checks if the environment file is of the correct format // If it doesnt match either of them, we throw an error - if (!HoppEnvKeyPairResult.success && HoppEnvExportObjectResult.type === "err") { + if ( + !HoppEnvKeyPairResult.success && + HoppEnvExportObjectResult.type === "err" + ) { throw error({ code: "MALFORMED_ENV_FILE", path, data: error }); } if (HoppEnvKeyPairResult.success) { for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) { - envPairs.push({ key, value }); + envPairs.push({ key, value, secret: false }); } } else if (HoppEnvExportObjectResult.type === "ok") { envPairs.push(...HoppEnvExportObjectResult.value.variables); diff --git a/packages/hoppscotch-cli/src/utils/checks.ts b/packages/hoppscotch-cli/src/utils/checks.ts index f935cbe4ac3..083aab7f2e2 100644 --- a/packages/hoppscotch-cli/src/utils/checks.ts +++ b/packages/hoppscotch-cli/src/utils/checks.ts @@ -1,5 +1,3 @@ -import { HoppCollection, isHoppRESTRequest } from "@hoppscotch/data"; -import * as A from "fp-ts/Array"; import { CommanderError } from "commander"; import { HoppCLIError, HoppErrnoException } from "../types/errors"; @@ -14,48 +12,6 @@ export const hasProperty =

( prop: P ): target is Record => prop in target; -/** - * Typeguard to check valid Hoppscotch REST Collection. - * @param param The object to be checked. - * @returns True, if unknown parameter is valid Hoppscotch REST Collection; - * False, otherwise. - */ -export const isRESTCollection = (param: unknown): param is HoppCollection => { - if (!!param && typeof param === "object") { - if (!hasProperty(param, "v") || typeof param.v !== "number") { - return false; - } - if (!hasProperty(param, "name") || typeof param.name !== "string") { - return false; - } - if (hasProperty(param, "id") && typeof param.id !== "string") { - return false; - } - if (!hasProperty(param, "requests") || !Array.isArray(param.requests)) { - return false; - } else { - // Checks each requests array to be valid HoppRESTRequest. - const checkRequests = A.every(isHoppRESTRequest)(param.requests); - if (!checkRequests) { - return false; - } - } - if (!hasProperty(param, "folders") || !Array.isArray(param.folders)) { - return false; - } else { - // Checks each folder to be valid REST collection. - const checkFolders = A.every(isRESTCollection)(param.folders); - if (!checkFolders) { - return false; - } - } - - return true; - } - - return false; -}; - /** * Checks if given error data is of type HoppCLIError, based on existence * of code property. diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 79053ed129f..91b1383f4e2 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -1,8 +1,11 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; import fs from "fs/promises"; -import { FormDataEntry } from "../types/request"; +import { entityReference } from "verzod"; +import { z } from "zod"; + import { error } from "../types/errors"; -import { isRESTCollection, isHoppErrnoException } from "./checks"; -import { HoppCollection } from "@hoppscotch/data"; +import { FormDataEntry } from "../types/request"; +import { isHoppErrnoException } from "./checks"; /** * Parses array of FormDataEntry to FormData. @@ -67,7 +70,11 @@ export async function parseCollectionData( ? contents : [contents]; - if (maybeArrayOfCollections.some((x) => !isRESTCollection(x))) { + const collectionSchemaParsedResult = z + .array(entityReference(HoppCollection)) + .safeParse(maybeArrayOfCollections); + + if (!collectionSchemaParsedResult.success) { throw error({ code: "MALFORMED_COLLECTION", path, @@ -75,5 +82,22 @@ export async function parseCollectionData( }); } - return maybeArrayOfCollections as HoppCollection[]; + return collectionSchemaParsedResult.data.map((collection) => { + const requestSchemaParsedResult = z + .array(entityReference(HoppRESTRequest)) + .safeParse(collection.requests); + + if (!requestSchemaParsedResult.success) { + throw error({ + code: "MALFORMED_COLLECTION", + path, + data: "Please check the collection data.", + }); + } + + return { + ...collection, + requests: requestSchemaParsedResult.data, + }; + }); } diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index b576ac9aa25..c4227c790ad 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -109,18 +109,31 @@ export function getEffectiveRESTRequest( key: "Authorization", value: `Basic ${btoa(`${username}:${password}`)}`, }); - } else if ( - request.auth.authType === "bearer" || - request.auth.authType === "oauth-2" - ) { + } else if (request.auth.authType === "bearer") { effectiveFinalHeaders.push({ active: true, key: "Authorization", - value: `Bearer ${parseTemplateString( - request.auth.token, - envVariables - )}`, + value: `Bearer ${parseTemplateString(request.auth.token, envVariables)}`, }); + } else if (request.auth.authType === "oauth-2") { + const { addTo } = request.auth; + + if (addTo === "HEADERS") { + effectiveFinalHeaders.push({ + active: true, + key: "Authorization", + value: `Bearer ${parseTemplateString(request.auth.grantTypeInfo.token, envVariables)}`, + }); + } else if (addTo === "QUERY_PARAMS") { + effectiveFinalParams.push({ + active: true, + key: "access_token", + value: parseTemplateString( + request.auth.grantTypeInfo.token, + envVariables + ), + }); + } } else if (request.auth.authType === "api-key") { const { key, value, addTo } = request.auth; if (addTo === "Headers") { diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 3e15b2979a8..81513bd210e 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -41,10 +41,10 @@ const processVariables = (variable: Environment["variables"][number]) => { ...variable, value: "value" in variable ? variable.value : process.env[variable.key] || "", - } + }; } - return variable -} + return variable; +}; /** * Processes given envs, which includes processing each variable in global @@ -56,10 +56,10 @@ const processEnvs = (envs: HoppEnvs) => { const processedEnvs = { global: envs.global.map(processVariables), selected: envs.selected.map(processVariables), - } + }; - return processedEnvs -} + return processedEnvs; +}; /** * Transforms given request data to request-config used by request-runner to @@ -70,7 +70,7 @@ const processEnvs = (envs: HoppEnvs) => { export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => { const config: RequestConfig = { supported: true, - displayUrl: req.effectiveFinalDisplayURL + displayUrl: req.effectiveFinalDisplayURL, }; const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest; const reqParams = finalParams(req); @@ -131,6 +131,7 @@ export const requestRunner = let status: number; const baseResponse = await axios(requestConfig); const { config } = baseResponse; + // PR-COMMENT: type error const runnerResponse: RequestRunnerResponse = { ...baseResponse, endpoint: getRequest.endpoint(config.url), @@ -257,10 +258,13 @@ export const processRequest = let updatedEnvs = {}; // Fetch values for secret environment variables from system environment - const processedEnvs = processEnvs(envs) + const processedEnvs = processEnvs(envs); // Executing pre-request-script - const preRequestRes = await preRequestScriptRunner(request, processedEnvs)(); + const preRequestRes = await preRequestScriptRunner( + request, + processedEnvs + )(); if (E.isLeft(preRequestRes)) { printPreRequestRunner.fail(); @@ -347,7 +351,7 @@ export const processRequest = */ export const preProcessRequest = ( request: HoppRESTRequest, - collection: HoppCollection, + collection: HoppCollection ): HoppRESTRequest => { const tempRequest = Object.assign({}, request); const { headers: parentHeaders, auth: parentAuth } = collection; @@ -372,8 +376,10 @@ export const preProcessRequest = ( // Filter out header entries present in the parent (folder/collection) under the same name // This ensures the child headers take precedence over the parent headers const filteredEntries = parentHeaders.filter((parentHeaderEntries) => { - return !tempRequest.headers.some((reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key) - }) + return !tempRequest.headers.some( + (reqHeaderEntries) => reqHeaderEntries.key === parentHeaderEntries.key + ); + }); tempRequest.headers.push(...filteredEntries); } else if (!tempRequest.headers) { tempRequest.headers = []; diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 8017fdadfae..dab2fe94b0d 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -27,6 +27,7 @@ "hide_secret": "Hide secret", "label": "Label", "learn_more": "Learn more", + "download_here": "Download here", "less": "Less", "more": "More", "new": "New", @@ -103,8 +104,10 @@ "auth": { "account_exists": "Account exists with different credential - Login to link both accounts", "all_sign_in_options": "All sign in options", + "continue_with_auth_provider": "Continue with {provider}", "continue_with_email": "Continue with Email", "continue_with_github": "Continue with GitHub", + "continue_with_github_enterprise": "Continue with GitHub Enterprise", "continue_with_google": "Continue with Google", "continue_with_microsoft": "Continue with Microsoft", "email": "Email", @@ -137,7 +140,26 @@ "redirect_no_token_endpoint": "No Token Endpoint Defined", "something_went_wrong_on_oauth_redirect": "Something went wrong during OAuth Redirect", "something_went_wrong_on_token_generation": "Something went wrong on token generation", - "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed" + "token_generation_oidc_discovery_failed": "Failure on token generation: OpenID Connect Discovery Failed", + "grant_type": "Grant Type", + "grant_type_auth_code": "Authorization Code", + "token_fetched_successfully": "Token fetched successfully", + "token_fetch_failed": "Failed to fetch token", + "validation_failed": "Validation Failed, please check the form fields", + "label_authorization_endpoint": "Authorization Endpoint", + "label_client_id": "Client ID", + "label_client_secret": "Client Secret", + "label_code_challenge": "Code Challenge", + "label_code_challenge_method": "Code Challenge Method", + "label_code_verifier": "Code Verifier", + "label_scopes": "Scopes", + "label_token_endpoint": "Token Endpoint", + "label_use_pkce": "Use PKCE", + "label_implicit": "Implicit", + "label_password": "Password", + "label_username": "Username", + "label_auth_code": "Authorization Code", + "label_client_credentials": "Client Credentials" }, "pass_key_by": "Pass by", "password": "Password", @@ -281,7 +303,7 @@ "updated": "Environment updated", "value": "Value", "variable": "Variable", - "variables":"Variables", + "variables": "Variables", "variable_list": "Variable List" }, "error": { @@ -427,7 +449,7 @@ "not_found": "Environment variable “{environment}” not found." }, "header": { - "cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead." + "cookie": "The browser doesn't allow Hoppscotch to set Cookie Headers. Please use Authorization Headers instead. However, our Hoppscotch Desktop App is live now and supports Cookies." }, "response": { "401_error": "Please check your authentication credentials.", @@ -961,7 +983,8 @@ "success_invites": "Success invites", "title": "Workspaces", "we_sent_invite_link": "We sent an invite link to all invitees!", - "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace." + "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace.", + "search_title": "Team Requests" }, "team_environment": { "deleted": "Environment Deleted", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 20783de26a0..75b5ec66eb9 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -1,5 +1,7 @@ -// generated by unplugin-vue-components -// We suggest you to commit this file into source control +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 import "@vue/runtime-core" diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue new file mode 100644 index 00000000000..109367c6df4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTTeamRequestEntry.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/index.vue b/packages/hoppscotch-common/src/components/app/spotlight/index.vue index 5081bfad13e..7da96ea7834 100644 --- a/packages/hoppscotch-common/src/components/app/spotlight/index.vue +++ b/packages/hoppscotch-common/src/components/app/spotlight/index.vue @@ -111,6 +111,7 @@ import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/ import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher" import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher" import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher" +import { TeamsSpotlightSearcherService } from "~/services/spotlight/searchers/teamRequest.searcher" import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher" import { SwitchWorkspaceSpotlightSearcherService, @@ -144,6 +145,7 @@ useService(SwitchEnvSpotlightSearcherService) useService(WorkspaceSpotlightSearcherService) useService(SwitchWorkspaceSpotlightSearcherService) useService(InterceptorSpotlightSearcherService) +useService(TeamsSpotlightSearcherService) platform.spotlight?.additionalSearchers?.forEach((searcher) => useService(searcher) diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue index f23c641733e..64267906529 100644 --- a/packages/hoppscotch-common/src/components/collections/Properties.vue +++ b/packages/hoppscotch-common/src/components/collections/Properties.vue @@ -8,7 +8,7 @@ > diff --git a/packages/hoppscotch-common/src/components/share/index.vue b/packages/hoppscotch-common/src/components/share/index.vue index 16431847359..3a635f51793 100644 --- a/packages/hoppscotch-common/src/components/share/index.vue +++ b/packages/hoppscotch-common/src/components/share/index.vue @@ -273,6 +273,10 @@ const loading = computed( ) onLoggedIn(() => { + if (adapter.isInitialized()) { + return + } + try { // wait for a bit to let the auth token to be set // because in some race conditions, the token is not set this fixes that diff --git a/packages/hoppscotch-common/src/components/teams/Team.vue b/packages/hoppscotch-common/src/components/teams/Team.vue index 123b63e74ef..76b51bf4e7e 100644 --- a/packages/hoppscotch-common/src/components/teams/Team.vue +++ b/packages/hoppscotch-common/src/components/teams/Team.vue @@ -131,6 +131,7 @@ @@ -161,6 +162,8 @@ import IconMoreVertical from "~icons/lucide/more-vertical" import IconUserX from "~icons/lucide/user-x" import IconUserPlus from "~icons/lucide/user-plus" import IconTrash2 from "~icons/lucide/trash-2" +import { useService } from "dioc/vue" +import { WorkspaceService } from "~/services/workspace.service" const t = useI18n() @@ -173,6 +176,7 @@ const props = defineProps<{ const emit = defineEmits<{ (e: "edit-team"): void (e: "invite-team"): void + (e: "refetch-teams"): void }>() const toast = useToast() @@ -180,7 +184,12 @@ const toast = useToast() const confirmRemove = ref(false) const confirmExit = ref(false) +const loading = ref(false) + +const workspaceService = useService(WorkspaceService) + const deleteTeam = () => { + loading.value = true pipe( backendDeleteTeam(props.teamID), TE.match( @@ -188,9 +197,25 @@ const deleteTeam = () => { // TODO: Better errors ? We know the possible errors now toast.error(`${t("error.something_went_wrong")}`) console.error(err) + loading.value = false + confirmRemove.value = false }, () => { toast.success(`${t("team.deleted")}`) + loading.value = false + emit("refetch-teams") + + const currentWorkspace = workspaceService.currentWorkspace.value + + // If the current workspace is the deleted workspace, change the workspace to personal + if ( + currentWorkspace.type === "team" && + currentWorkspace.teamID === props.teamID + ) { + workspaceService.changeWorkspace({ type: "personal" }) + } + + confirmRemove.value = false } ) )() // Tasks (and TEs) are lazy, so call the function returned diff --git a/packages/hoppscotch-common/src/components/teams/index.vue b/packages/hoppscotch-common/src/components/teams/index.vue index 2c3972ec5e3..85c7206f9ef 100644 --- a/packages/hoppscotch-common/src/components/teams/index.vue +++ b/packages/hoppscotch-common/src/components/teams/index.vue @@ -4,6 +4,7 @@

@@ -16,13 +17,6 @@ :alt="`${t('empty.teams')}`" :text="`${t('empty.teams')}`" > -
@@ -76,6 +71,7 @@ import { useReadonlyStream } from "@composables/stream" import { useColorMode } from "@composables/theming" import { WorkspaceService } from "~/services/workspace.service" import { useService } from "dioc/vue" +import IconPlus from "~icons/lucide/plus" const t = useI18n() diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index c8cc15f302b..b09a644d287 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -68,6 +68,9 @@ type CodeMirrorOptions = { // callback on editor update onUpdate?: (view: ViewUpdate) => void + + // callback on view initialization + onInit?: (view: EditorView) => void } const hoppCompleterExt = (completer: Completer): Extension => { @@ -208,7 +211,9 @@ export function useCodemirror( el: Ref, value: Ref, options: CodeMirrorOptions -): { cursor: Ref<{ line: number; ch: number }> } { +): { + cursor: Ref<{ line: number; ch: number }> +} { const { subscribeToStream } = useStreamSubscriber() // Set default value for contextMenuEnabled if not provided @@ -383,6 +388,8 @@ export function useCodemirror( extensions, }), }) + + options.onInit?.(view.value) } onMounted(() => { diff --git a/packages/hoppscotch-common/src/composables/ref.ts b/packages/hoppscotch-common/src/composables/ref.ts index 446d8ba506f..5d9cdbd0877 100644 --- a/packages/hoppscotch-common/src/composables/ref.ts +++ b/packages/hoppscotch-common/src/composables/ref.ts @@ -1,4 +1,4 @@ -import { customRef, onBeforeUnmount, Ref, watch } from "vue" +import { customRef, onBeforeUnmount, ref, Ref, UnwrapRef, watch } from "vue" export function pluckRef(ref: Ref, key: K): Ref { return customRef((track, trigger) => { @@ -31,3 +31,16 @@ export function pluckMultipleFromRef>( ): { [key in K[number]]: Ref } { return Object.fromEntries(keys.map((x) => [x, pluckRef(sourceRef, x)])) as any } + +export const refWithCallbackOnChange = ( + initialValue: T, + callback: (value: UnwrapRef) => void +) => { + const targetRef = ref(initialValue) + + watch(targetRef, (value) => { + callback(value) + }) + + return targetRef +} diff --git a/packages/hoppscotch-common/src/helpers/collection/request.ts b/packages/hoppscotch-common/src/helpers/collection/request.ts index 31f0d54495d..d5576c3d7ed 100644 --- a/packages/hoppscotch-common/src/helpers/collection/request.ts +++ b/packages/hoppscotch-common/src/helpers/collection/request.ts @@ -66,12 +66,20 @@ export function getRequestsByPath( let currentCollection = collections[pathArray[0]] if (pathArray.length === 1) { - return currentCollection.requests + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === "3" + ) + + return latestVersionedRequests } for (let i = 1; i < pathArray.length; i++) { const folder = currentCollection.folders[pathArray[i]] if (folder) currentCollection = folder } - return currentCollection.requests + const latestVersionedRequests = currentCollection.requests.filter( + (req): req is HoppRESTRequest => req.v === "3" + ) + + return latestVersionedRequests } diff --git a/packages/hoppscotch-common/src/helpers/graphql/connection.ts b/packages/hoppscotch-common/src/helpers/graphql/connection.ts index 3d603c45d3e..e393ddc7816 100644 --- a/packages/hoppscotch-common/src/helpers/graphql/connection.ts +++ b/packages/hoppscotch-common/src/helpers/graphql/connection.ts @@ -269,8 +269,16 @@ export const runGQLOperation = async (options: RunQueryOptions) => { const username = auth.username const password = auth.password finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}` - } else if (auth.authType === "bearer" || auth.authType === "oauth-2") { + } else if (auth.authType === "bearer") { finalHeaders.Authorization = `Bearer ${auth.token}` + } else if (auth.authType === "oauth-2") { + const { addTo } = auth + + if (addTo === "HEADERS") { + finalHeaders.Authorization = `Bearer ${auth.grantTypeInfo.token}` + } else if (addTo === "QUERY_PARAMS") { + params["access_token"] = auth.grantTypeInfo.token + } } else if (auth.authType === "api-key") { const { key, value, addTo } = auth if (addTo === "Headers") { 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 5cbd4b16975..a69dbb9c3e0 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia.ts @@ -111,12 +111,16 @@ const getHoppReqAuth = (req: InsomniaRequestResource): HoppRESTAuth => { return { authType: "oauth-2", authActive: !(auth.disabled ?? false), - accessTokenURL: replaceVarTemplating(auth.accessTokenUrl ?? ""), - authURL: replaceVarTemplating(auth.authorizationUrl ?? ""), - clientID: replaceVarTemplating(auth.clientId ?? ""), - oidcDiscoveryURL: "", - scope: replaceVarTemplating(auth.scope ?? ""), - token: "", + grantTypeInfo: { + authEndpoint: replaceVarTemplating(auth.authorizationUrl ?? ""), + clientID: replaceVarTemplating(auth.clientId ?? ""), + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: replaceVarTemplating(auth.scope ?? ""), + token: "", + isPKCE: false, + tokenEndpoint: replaceVarTemplating(auth.accessTokenUrl ?? ""), + }, } else if (auth.type === "bearer") return { 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 cc3d8cb9c21..5be63660601 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi.ts @@ -279,67 +279,92 @@ const resolveOpenAPIV3SecurityObj = ( return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.flows.authorizationCode.tokenUrl ?? "", - authURL: scheme.flows.authorizationCode.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: scheme.flows.authorizationCode.authorizationUrl ?? "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.flows.authorizationCode.tokenUrl ?? "", + clientSecret: "", + }, + addTo: "HEADERS", } } else if (scheme.flows.implicit) { return { authType: "oauth-2", authActive: true, - authURL: scheme.flows.implicit.authorizationUrl ?? "", - accessTokenURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "IMPLICIT", + authEndpoint: scheme.flows.implicit.authorizationUrl ?? "", + clientID: "", + token: "", + scopes: _schemeData.join(" "), + }, + addTo: "HEADERS", } } else if (scheme.flows.password) { return { authType: "oauth-2", authActive: true, - authURL: "", - accessTokenURL: scheme.flows.password.tokenUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "PASSWORD", + clientID: "", + authEndpoint: scheme.flows.password.tokenUrl, + clientSecret: "", + password: "", + username: "", + token: "", + scopes: _schemeData.join(" "), + }, + addTo: "HEADERS", } } else if (scheme.flows.clientCredentials) { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.flows.clientCredentials.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "CLIENT_CREDENTIALS", + authEndpoint: scheme.flows.clientCredentials.tokenUrl ?? "", + clientID: "", + clientSecret: "", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + }, + addTo: "HEADERS", } } else if (scheme.type === "openIdConnect") { return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: scheme.openIdConnectUrl ?? "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: "", + clientID: "", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + clientSecret: "", + }, + addTo: "HEADERS", } } @@ -416,56 +441,76 @@ const resolveOpenAPIV2SecurityScheme = ( return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: scheme.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: scheme.tokenUrl ?? "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "implicit") { return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: scheme.authorizationUrl ?? "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.authorizationUrl ?? "", + clientID: "", + grantType: "IMPLICIT", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "application") { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + grantType: "CLIENT_CREDENTIALS", + scopes: _schemeData.join(" "), + token: "", + }, + addTo: "HEADERS", } } else if (scheme.flow === "password") { return { authType: "oauth-2", authActive: true, - accessTokenURL: scheme.tokenUrl ?? "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + grantType: "PASSWORD", + authEndpoint: scheme.tokenUrl ?? "", + clientID: "", + clientSecret: "", + password: "", + scopes: _schemeData.join(" "), + token: "", + username: "", + }, + addTo: "HEADERS", } } return { authType: "oauth-2", authActive: true, - accessTokenURL: "", - authURL: "", - clientID: "", - oidcDiscoveryURL: "", - scope: _schemeData.join(" "), - token: "", + grantTypeInfo: { + authEndpoint: "", + clientID: "", + clientSecret: "", + grantType: "AUTHORIZATION_CODE", + scopes: _schemeData.join(" "), + token: "", + isPKCE: false, + tokenEndpoint: "", + }, + addTo: "HEADERS", } } 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 c9c9d8eb3a4..07c21a9237f 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -162,25 +162,36 @@ const getHoppReqAuth = (item: Item): HoppRESTAuth => { ), } } else if (auth.type === "oauth2") { + const accessTokenURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessTokenUrl") ?? "" + ) + const authURL = replacePMVarTemplating( + getVariableValue(auth.oauth2, "authUrl") ?? "" + ) + const clientId = replacePMVarTemplating( + getVariableValue(auth.oauth2, "clientId") ?? "" + ) + const scope = replacePMVarTemplating( + getVariableValue(auth.oauth2, "scope") ?? "" + ) + const token = replacePMVarTemplating( + getVariableValue(auth.oauth2, "accessToken") ?? "" + ) + return { authType: "oauth-2", authActive: true, - accessTokenURL: replacePMVarTemplating( - getVariableValue(auth.oauth2, "accessTokenUrl") ?? "" - ), - authURL: replacePMVarTemplating( - getVariableValue(auth.oauth2, "authUrl") ?? "" - ), - clientID: replacePMVarTemplating( - getVariableValue(auth.oauth2, "clientId") ?? "" - ), - scope: replacePMVarTemplating( - getVariableValue(auth.oauth2, "scope") ?? "" - ), - token: replacePMVarTemplating( - getVariableValue(auth.oauth2, "accessToken") ?? "" - ), - oidcDiscoveryURL: "", + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE", + authEndpoint: authURL, + clientID: clientId, + scopes: scope, + token: token, + tokenEndpoint: accessTokenURL, + clientSecret: "", + isPKCE: false, + }, + addTo: "HEADERS", } } diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts new file mode 100644 index 00000000000..015a611d551 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts @@ -0,0 +1,611 @@ +import { ref } from "vue" +import { runGQLQuery } from "../backend/GQLClient" +import { + GetCollectionChildrenDocument, + GetCollectionRequestsDocument, + GetSingleCollectionDocument, + GetSingleRequestDocument, +} from "../backend/graphql" +import { TeamCollection } from "./TeamCollection" +import { HoppRESTAuth, HoppRESTHeader } from "@hoppscotch/data" + +import * as E from "fp-ts/Either" +import { HoppInheritedProperty } from "../types/HoppInheritedProperties" +import { TeamRequest } from "./TeamRequest" +import { Service } from "dioc" +import axios from "axios" +import { Ref } from "vue" +import { platform } from "~/platform" + +type CollectionSearchMeta = { + isSearchResult?: boolean + insertedWhileExpanding?: boolean +} + +type CollectionSearchNode = + | { + type: "request" + title: string + method: string + id: string + // parent collections + path: CollectionSearchNode[] + } + | { + type: "collection" + title: string + id: string + // parent collections + path: CollectionSearchNode[] + } + +type _SearchCollection = TeamCollection & { + parentID: string | null + meta?: CollectionSearchMeta +} + +type _SearchRequest = { + id: string + collectionID: string + title: string + request: { + name: string + method: string + } + meta?: CollectionSearchMeta +} + +function convertToTeamCollection( + node: CollectionSearchNode & { + meta?: CollectionSearchMeta + }, + existingCollections: Record, + existingRequests: Record +) { + if (node.type === "request") { + existingRequests[node.id] = { + id: node.id, + collectionID: node.path[0].id, + title: node.title, + request: { + name: node.title, + method: node.method, + }, + meta: { + isSearchResult: node.meta?.isSearchResult || false, + }, + } + + if (node.path[0]) { + // add parent collections to the collections array recursively + convertToTeamCollection( + node.path[0], + existingCollections, + existingRequests + ) + } + } else { + existingCollections[node.id] = { + id: node.id, + title: node.title, + children: [], + requests: [], + data: null, + parentID: node.path[0]?.id, + meta: { + isSearchResult: node.meta?.isSearchResult || false, + }, + } + + if (node.path[0]) { + // add parent collections to the collections array recursively + convertToTeamCollection( + node.path[0], + existingCollections, + existingRequests + ) + } + } + + return { + existingCollections, + existingRequests, + } +} + +function convertToTeamTree( + collections: (TeamCollection & { parentID: string | null })[], + requests: TeamRequest[] +) { + const collectionTree: TeamCollection[] = [] + + collections.forEach((collection) => { + const parentCollection = collection.parentID + ? collections.find((c) => c.id === collection.parentID) + : null + + const isAlreadyInserted = parentCollection?.children?.find( + (c) => c.id === collection.id + ) + + if (isAlreadyInserted) return + + if (parentCollection) { + parentCollection.children = parentCollection.children || [] + parentCollection.children.push(collection) + } else { + collectionTree.push(collection) + } + }) + + requests.forEach((request) => { + const parentCollection = collections.find( + (c) => c.id === request.collectionID + ) + + const isAlreadyInserted = parentCollection?.requests?.find( + (r) => r.id === request.id + ) + + if (isAlreadyInserted) return + + if (parentCollection) { + parentCollection.requests = parentCollection.requests || [] + parentCollection.requests.push({ + id: request.id, + collectionID: request.collectionID, + title: request.title, + request: request.request, + }) + } + }) + + return collectionTree +} + +export class TeamSearchService extends Service { + public static readonly ID = "TeamSearchService" + + public endpoint = import.meta.env.VITE_BACKEND_API_URL + + public teamsSearchResultsLoading = ref(false) + public teamsSearchResults = ref([]) + public teamsSearchResultsFormattedForSpotlight = ref< + { + collectionTitles: string[] + request: { + id: string + name: string + method: string + } + }[] + >([]) + + searchResultsCollections: Record = {} + searchResultsRequests: Record = {} + + expandingCollections: Ref = ref([]) + expandedCollections: Ref = ref([]) + + // FUTURE-TODO: ideally this should return the search results / formatted results instead of directly manipulating the result set + // eg: do the spotlight formatting in the spotlight searcher and not here + searchTeams = async (query: string, teamID: string) => { + if (!query.length) { + return + } + + this.teamsSearchResultsLoading.value = true + + this.searchResultsCollections = {} + this.searchResultsRequests = {} + this.expandedCollections.value = [] + + const axiosPlatformConfig = platform.auth.axiosPlatformConfig?.() ?? {} + + try { + const searchResponse = await axios.get( + `${ + this.endpoint + }/team-collection/search/${teamID}?searchQuery=${encodeURIComponent( + query + )}`, + axiosPlatformConfig + ) + + if (searchResponse.status !== 200) { + return + } + + const searchResults = searchResponse.data.data as CollectionSearchNode[] + + searchResults + .map((node) => { + const { existingCollections, existingRequests } = + convertToTeamCollection( + { + ...node, + meta: { + isSearchResult: true, + }, + }, + {}, + {} + ) + + return { + collections: existingCollections, + requests: existingRequests, + } + }) + .forEach(({ collections, requests }) => { + this.searchResultsCollections = { + ...this.searchResultsCollections, + ...collections, + } + this.searchResultsRequests = { + ...this.searchResultsRequests, + ...requests, + } + }) + + const collectionFetchingPromises = Object.values( + this.searchResultsCollections + ).map((col) => { + return getSingleCollection(col.id) + }) + + const requestFetchingPromises = Object.values( + this.searchResultsRequests + ).map((req) => { + return getSingleRequest(req.id) + }) + + const collectionResponses = await Promise.all(collectionFetchingPromises) + const requestResponses = await Promise.all(requestFetchingPromises) + + requestResponses.map((res) => { + if (E.isLeft(res)) { + return + } + + const request = res.right.request + + if (!request) return + + this.searchResultsRequests[request.id] = { + id: request.id, + title: request.title, + request: JSON.parse(request.request) as TeamRequest["request"], + collectionID: request.collectionID, + } + }) + + collectionResponses.map((res) => { + if (E.isLeft(res)) { + return + } + + const collection = res.right.collection + + if (!collection) return + + this.searchResultsCollections[collection.id].data = + collection.data ?? null + }) + + const collectionTree = convertToTeamTree( + Object.values(this.searchResultsCollections), + // asserting because we've already added the missing properties after fetching the full details + Object.values(this.searchResultsRequests) as TeamRequest[] + ) + + this.teamsSearchResults.value = collectionTree + + this.teamsSearchResultsFormattedForSpotlight.value = Object.values( + this.searchResultsRequests + ).map((request) => { + return formatTeamsSearchResultsForSpotlight( + { + collectionID: request.collectionID, + name: request.title, + method: request.request.method, + id: request.id, + }, + Object.values(this.searchResultsCollections) + ) + }) + } catch (error) { + console.error(error) + } + + this.teamsSearchResultsLoading.value = false + } + + cascadeParentCollectionForHeaderAuthForSearchResults = ( + collectionID: string + ): HoppInheritedProperty => { + const defaultInheritedAuth: HoppInheritedProperty["auth"] = { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + } + + const defaultInheritedHeaders: HoppInheritedProperty["headers"] = [] + + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) + return { auth: defaultInheritedAuth, headers: defaultInheritedHeaders } + + const inheritedAuthData = this.findInheritableParentAuth(collectionID) + const inheritedHeadersData = this.findInheritableParentHeaders(collectionID) + + return { + auth: E.isRight(inheritedAuthData) + ? inheritedAuthData.right + : defaultInheritedAuth, + headers: E.isRight(inheritedHeadersData) + ? Object.values(inheritedHeadersData.right) + : defaultInheritedHeaders, + } + } + + findInheritableParentAuth = ( + collectionID: string + ): E.Either< + string, + { + parentID: string + parentName: string + inheritedAuth: HoppRESTAuth + } + > => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // has inherited data + if (collection.data) { + const parentInheritedData = JSON.parse(collection.data) as { + auth: HoppRESTAuth + headers: HoppRESTHeader[] + } + + const inheritedAuth = parentInheritedData.auth + + if (inheritedAuth.authType !== "inherit") { + return E.right({ + parentID: collectionID, + parentName: collection.title, + inheritedAuth: inheritedAuth, + }) + } + } + + if (!collection.parentID) { + return E.left("PARENT_INHERITED_DATA_NOT_FOUND") + } + + return this.findInheritableParentAuth(collection.parentID) + } + + findInheritableParentHeaders = ( + collectionID: string, + existingHeaders: Record< + string, + HoppInheritedProperty["headers"][number] + > = {} + ): E.Either< + string, + Record + > => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // see if it has headers to inherit, if yes, add it to the existing headers + if (collection.data) { + const parentInheritedData = JSON.parse(collection.data) as { + auth: HoppRESTAuth + headers: HoppRESTHeader[] + } + + const inheritedHeaders = parentInheritedData.headers + + if (inheritedHeaders) { + inheritedHeaders.forEach((header) => { + if (!existingHeaders[header.key]) { + existingHeaders[header.key] = { + parentID: collection.id, + parentName: collection.title, + inheritedHeader: header, + } + } + }) + } + } + + if (collection.parentID) { + return this.findInheritableParentHeaders( + collection.parentID, + existingHeaders + ) + } + + return E.right(existingHeaders) + } + + expandCollection = async (collectionID: string) => { + if (this.expandingCollections.value.includes(collectionID)) return + + const collectionToExpand = Object.values( + this.searchResultsCollections + ).find((col) => col.id === collectionID) + + const isAlreadyExpanded = + this.expandedCollections.value.includes(collectionID) + + // only allow search result collections to be expanded + if ( + isAlreadyExpanded || + !collectionToExpand || + !( + collectionToExpand.meta?.isSearchResult || + collectionToExpand.meta?.insertedWhileExpanding + ) + ) + return + + this.expandingCollections.value.push(collectionID) + + const childCollectionsPromise = getCollectionChildCollections(collectionID) + const childRequestsPromise = getCollectionChildRequests(collectionID) + + const [childCollections, childRequests] = await Promise.all([ + childCollectionsPromise, + childRequestsPromise, + ]) + + if (E.isLeft(childCollections)) { + return + } + + if (E.isLeft(childRequests)) { + return + } + + childCollections.right.collection?.children + .map((child) => ({ + id: child.id, + title: child.title, + data: child.data ?? null, + children: [], + requests: [], + })) + .forEach((child) => { + this.searchResultsCollections[child.id] = { + ...child, + parentID: collectionID, + meta: { + isSearchResult: false, + insertedWhileExpanding: true, + }, + } + }) + + childRequests.right.requestsInCollection + .map((request) => ({ + id: request.id, + collectionID: collectionID, + title: request.title, + request: JSON.parse(request.request) as TeamRequest["request"], + })) + .forEach((request) => { + this.searchResultsRequests[request.id] = { + ...request, + meta: { + isSearchResult: false, + insertedWhileExpanding: true, + }, + } + }) + + this.teamsSearchResults.value = convertToTeamTree( + Object.values(this.searchResultsCollections), + // asserting because we've already added the missing properties after fetching the full details + Object.values(this.searchResultsRequests) as TeamRequest[] + ) + + // remove the collection after expanding + this.expandingCollections.value = this.expandingCollections.value.filter( + (colID) => colID !== collectionID + ) + + this.expandedCollections.value.push(collectionID) + } +} + +const getSingleCollection = (collectionID: string) => + runGQLQuery({ + query: GetSingleCollectionDocument, + variables: { + collectionID, + }, + }) + +const getSingleRequest = (requestID: string) => + runGQLQuery({ + query: GetSingleRequestDocument, + variables: { + requestID, + }, + }) + +const getCollectionChildCollections = (collectionID: string) => + runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID, + }, + }) + +const getCollectionChildRequests = (collectionID: string) => + runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID, + }, + }) + +const formatTeamsSearchResultsForSpotlight = ( + request: { + collectionID: string + name: string + method: string + id: string + }, + parentCollections: (TeamCollection & { parentID: string | null })[] +) => { + let collectionTitles: string[] = [] + + let parentCollectionID: string | null = request.collectionID + + while (true) { + if (!parentCollectionID) { + break + } + + const parentCollection = parentCollections.find( + (col) => col.id === parentCollectionID + ) + + if (!parentCollection) { + break + } + + collectionTitles = [parentCollection.title, ...collectionTitles] + parentCollectionID = parentCollection.parentID + } + + return { + collectionTitles, + request: { + name: request.name, + method: request.method, + id: request.id, + }, + } +} diff --git a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts index 6b3721ff219..a11400bfa2f 100644 --- a/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts +++ b/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts @@ -82,16 +82,17 @@ export const getComputedAuthHeaders = ( }) } else if ( request.auth.authType === "bearer" || - request.auth.authType === "oauth-2" + (request.auth.authType === "oauth-2" && request.auth.addTo === "HEADERS") ) { + const token = + request.auth.authType === "bearer" + ? request.auth.token + : request.auth.grantTypeInfo.token + headers.push({ active: true, key: "Authorization", - value: `Bearer ${ - parse - ? parseTemplateString(request.auth.token, envVars) - : request.auth.token - }`, + value: `Bearer ${parse ? parseTemplateString(token, envVars) : token}`, }) } else if (request.auth.authType === "api-key") { const { key, addTo } = request.auth @@ -196,17 +197,40 @@ export const getComputedParams = ( ): ComputedParam[] => { // When this gets complex, its best to split this function off (like with getComputedHeaders) // API-key auth can be added to query params - if (!req.auth || !req.auth.authActive) return [] - if (req.auth.authType !== "api-key") return [] - if (req.auth.addTo !== "Query params") return [] + if (!req.auth || !req.auth.authActive) { + return [] + } + + if (req.auth.authType !== "api-key" && req.auth.authType !== "oauth-2") { + return [] + } + + if (req.auth.addTo !== "QUERY_PARAMS") { + return [] + } + + if (req.auth.authType === "api-key") { + return [ + { + source: "auth" as const, + param: { + active: true, + key: parseTemplateString(req.auth.key, envVars), + value: parseTemplateString(req.auth.value, envVars), + }, + }, + ] + } + + const { grantTypeInfo } = req.auth return [ { source: "auth", param: { active: true, - key: parseTemplateString(req.auth.key, envVars), - value: parseTemplateString(req.auth.value, envVars), + key: "access_token", + value: parseTemplateString(grantTypeInfo.token, envVars), }, }, ] @@ -250,7 +274,7 @@ function getFinalBodyFromRequest( if (request.body.contentType === "application/x-www-form-urlencoded") { const parsedBodyRecord = pipe( - request.body.body, + request.body.body ?? "", parseRawKeyValueEntriesE, E.map( flow( @@ -287,7 +311,7 @@ function getFinalBodyFromRequest( if (request.body.contentType === "multipart/form-data") { return pipe( - request.body.body, + request.body.body ?? [], A.filter((x) => (x.key !== "" || x.isFile) && x.active), // Remove empty keys // Sort files down diff --git a/packages/hoppscotch-common/src/pages/import.vue b/packages/hoppscotch-common/src/pages/import.vue index c0ceb667dcb..3ce0fd3e294 100644 --- a/packages/hoppscotch-common/src/pages/import.vue +++ b/packages/hoppscotch-common/src/pages/import.vue @@ -79,7 +79,7 @@ const importCollections = (url: unknown, type: unknown) => content.data, TO.fromPredicate(isOfType("string")), TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT), - TE.chain((data) => importer.importer(data)) + TE.chain((data) => importer.importer([data])) ) ) ) diff --git a/packages/hoppscotch-common/src/pages/oauth.vue b/packages/hoppscotch-common/src/pages/oauth.vue index 14d3d766e5f..912d7848e9c 100644 --- a/packages/hoppscotch-common/src/pages/oauth.vue +++ b/packages/hoppscotch-common/src/pages/oauth.vue @@ -5,23 +5,31 @@ diff --git a/packages/hoppscotch-common/src/platform/auth.ts b/packages/hoppscotch-common/src/platform/auth.ts index d2920c46e1c..d32dce9e239 100644 --- a/packages/hoppscotch-common/src/platform/auth.ts +++ b/packages/hoppscotch-common/src/platform/auth.ts @@ -3,6 +3,7 @@ import { Observable } from "rxjs" import { Component } from "vue" import { getI18n } from "~/modules/i18n" import * as E from "fp-ts/Either" +import { AxiosRequestConfig } from "axios" /** * A common (and required) set of fields that describe a user. @@ -135,6 +136,15 @@ export type AuthPlatformDef = { */ getGQLClientOptions?: () => Partial + /** + * called by the platform to provide additional/different config options when + * sending requests with axios + * eg: SH needs to include cookies in the request, while Central doesn't and throws a cors error if it does + * + * @returns AxiosRequestConfig + */ + axiosPlatformConfig?: () => AxiosRequestConfig + /** * Returns the string content that should be returned when the user selects to * copy auth token from Developer Options. diff --git a/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts b/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts index a6095c0bc40..64395b124bb 100644 --- a/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts +++ b/packages/hoppscotch-common/src/platform/std/interceptors/extension.ts @@ -231,6 +231,7 @@ export class ExtensionInterceptorService try { const result = await extensionHook.sendRequest({ ...req, + headers: req.headers ?? {}, wantsBinary: true, }) diff --git a/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts index e5124d068cd..aa711be6c36 100644 --- a/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts +++ b/packages/hoppscotch-common/src/services/inspection/inspectors/header.inspector.ts @@ -66,8 +66,8 @@ export class HeaderInspectorService extends Service implements Inspector { index: index, }, doc: { - text: this.t("action.learn_more"), - link: "https://docs.hoppscotch.io/documentation/features/inspections", + text: this.t("action.download_here"), + link: "https://hoppscotch.com/download", }, }) } diff --git a/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts new file mode 100644 index 00000000000..f46db721239 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/authCode.ts @@ -0,0 +1,293 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + decodeResponseAsJSON, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { AuthCodeGrantTypeParams } from "@hoppscotch/data" + +const persistenceService = getService(PersistenceService) +const interceptorService = getService(InterceptorService) + +const AuthCodeOauthFlowParamsSchema = AuthCodeGrantTypeParams.pick({ + authEndpoint: true, + tokenEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + isPKCE: true, + codeVerifierMethod: true, +}) + .refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.tokenEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + (!params.scopes || params.scopes.trim().length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } + ) + .refine((params) => (params.isPKCE ? !!params.codeVerifierMethod : true), { + message: "codeVerifierMethod is required when using PKCE", + path: ["codeVerifierMethod"], + }) + +export type AuthCodeOauthFlowParams = z.infer< + typeof AuthCodeOauthFlowParamsSchema +> + +export const getDefaultAuthCodeOauthFlowParams = + (): AuthCodeOauthFlowParams => ({ + authEndpoint: "", + tokenEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + isPKCE: false, + codeVerifierMethod: "S256", + }) + +const initAuthCodeOauthFlow = async ({ + tokenEndpoint, + clientID, + clientSecret, + scopes, + authEndpoint, + isPKCE, + codeVerifierMethod, +}: AuthCodeOauthFlowParams) => { + const state = generateRandomString() + + let codeVerifier: string | undefined + let codeChallenge: string | undefined + + if (isPKCE) { + codeVerifier = generateCodeVerifier() + codeChallenge = await generateCodeChallenge( + codeVerifier, + codeVerifierMethod + ) + } + + let oauthTempConfig: { + state: string + grant_type: "AUTHORIZATION_CODE" + authEndpoint: string + tokenEndpoint: string + clientSecret: string + clientID: string + isPKCE: boolean + codeVerifier?: string + codeVerifierMethod?: string + codeChallenge?: string + scopes?: string + } = { + state, + grant_type: "AUTHORIZATION_CODE", + authEndpoint, + tokenEndpoint, + clientSecret, + clientID, + isPKCE, + codeVerifierMethod, + scopes, + } + + if (codeVerifier && codeChallenge) { + oauthTempConfig = { + ...oauthTempConfig, + codeVerifier, + codeChallenge, + } + } + + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + const { grant_type, ...rest } = oauthTempConfig + + // persist the state so we can compare it when we get redirected back + // also persist the grant_type,tokenEndpoint and clientSecret so we can use them when we get redirected back + persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: rest, + grant_type, + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch (e) { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("grant_type", "authorization_code") + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + if (codeVerifierMethod && codeChallenge) { + url.searchParams.set("code_challenge", codeChallenge) + url.searchParams.set("code_challenge_method", codeVerifierMethod) + } + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + codeVerifier: z.string().optional(), + codeChallenge: z.string().optional(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("grant_type", "authorization_code") + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + if (decodedLocalConfig.data.codeVerifier) { + formData.append("code_verifier", decodedLocalConfig.data.codeVerifier) + } + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const generateCodeVerifier = () => { + const characters = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43 // Random length between 43 and 128 + let codeVerifier = "" + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length) + codeVerifier += characters[randomIndex] + } + + return codeVerifier +} + +const generateCodeChallenge = async ( + codeVerifier: string, + strategy: AuthCodeOauthFlowParams["codeVerifierMethod"] +) => { + if (strategy === "plain") { + return codeVerifier + } + + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) + + const buffer = await crypto.subtle.digest("SHA-256", data) + + return encodeArrayBufferAsUrlEncodedBase64(buffer) +} + +const encodeArrayBufferAsUrlEncodedBase64 = (buffer: ArrayBuffer) => { + const hashArray = Array.from(new Uint8Array(buffer)) + const hashBase64URL = btoa(String.fromCharCode(...hashArray)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_") + + return hashBase64URL +} + +export default createFlowConfig( + "AUTHORIZATION_CODE" as const, + AuthCodeOauthFlowParamsSchema, + initAuthCodeOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts new file mode 100644 index 00000000000..9582b823666 --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/clientCredentials.ts @@ -0,0 +1,183 @@ +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { useToast } from "~/composables/toast" +import { ClientCredentialsGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(InterceptorService) + +const ClientCredentialsFlowParamsSchema = ClientCredentialsGrantTypeParams.pick( + { + authEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + } +).refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } +) + +export type ClientCredentialsFlowParams = z.infer< + typeof ClientCredentialsFlowParamsSchema +> + +export const getDefaultClientCredentialsFlowParams = + (): ClientCredentialsFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + }) + +const initClientCredentialsOAuthFlow = async ({ + clientID, + clientSecret, + scopes, + authEndpoint, +}: ClientCredentialsFlowParams) => { + const toast = useToast() + + const formData = new URLSearchParams() + formData.append("grant_type", "client_credentials") + formData.append("client_id", clientID) + formData.append("client_secret", clientSecret) + + if (scopes) { + formData.append("scope", scopes) + } + + const { response } = interceptorService.runRequest({ + url: authEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + if (!parsedTokenResponse.success) { + toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") + } + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = new TextDecoder("utf-8") + .decode(res.right.data as any) + .replaceAll("\x00", "") + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + JSON.parse(responsePayload) + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "CLIENT_CREDENTIALS" as const, + ClientCredentialsFlowParamsSchema, + initClientCredentialsOAuthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts new file mode 100644 index 00000000000..08293c604ee --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/implicit.ts @@ -0,0 +1,135 @@ +import { PersistenceService } from "~/services/persistence" +import { + OauthAuthService, + PersistedOAuthConfig, + createFlowConfig, + generateRandomString, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { ImplicitOauthFlowParams } from "@hoppscotch/data" + +const persistenceService = getService(PersistenceService) + +const ImplicitOauthFlowParamsSchema = ImplicitOauthFlowParams.pick({ + authEndpoint: true, + clientID: true, + scopes: true, +}).refine((params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + (params.scopes === undefined || params.scopes.length >= 1) + ) +}) + +export type ImplicitOauthFlowParams = z.infer< + typeof ImplicitOauthFlowParamsSchema +> + +export const getDefaultImplicitOauthFlowParams = + (): ImplicitOauthFlowParams => ({ + authEndpoint: "", + clientID: "", + scopes: undefined, + }) + +const initImplicitOauthFlow = async ({ + clientID, + scopes, + authEndpoint, +}: ImplicitOauthFlowParams) => { + const state = generateRandomString() + + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + const persistedOAuthConfig: PersistedOAuthConfig = localOAuthTempConfig + ? { ...JSON.parse(localOAuthTempConfig) } + : {} + + // Persist the necessary information for retrieval while getting redirected back + persistenceService.setLocalConfig( + "oauth_temp_config", + JSON.stringify({ + ...persistedOAuthConfig, + fields: { + clientID, + authEndpoint, + scopes, + state, + }, + grant_type: "IMPLICIT", + }) + ) + + let url: URL + + try { + url = new URL(authEndpoint) + } catch { + return E.left("INVALID_AUTH_ENDPOINT") + } + + url.searchParams.set("client_id", clientID) + url.searchParams.set("state", state) + url.searchParams.set("response_type", "token") + url.searchParams.set("redirect_uri", OauthAuthService.redirectURI) + + if (scopes) url.searchParams.set("scope", scopes) + + // Redirect to the authorization server + window.location.assign(url.toString()) + + return E.right(undefined) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + const paramsFromHash = new URLSearchParams(window.location.hash.substring(1)) + + const accessToken = + params.get("access_token") || paramsFromHash.get("access_token") + const state = params.get("state") || paramsFromHash.get("state") + const error = params.get("error") || paramsFromHash.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!accessToken) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + state: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localConfig).fields + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + return E.right({ + access_token: accessToken, + }) +} + +export default createFlowConfig( + "IMPLICIT" as const, + ImplicitOauthFlowParamsSchema, + initImplicitOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/flows/password.ts b/packages/hoppscotch-common/src/services/oauth/flows/password.ts new file mode 100644 index 00000000000..d572b64719c --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/flows/password.ts @@ -0,0 +1,189 @@ +import { + OauthAuthService, + createFlowConfig, + decodeResponseAsJSON, +} from "../oauth.service" +import { z } from "zod" +import { getService } from "~/modules/dioc" +import * as E from "fp-ts/Either" +import { InterceptorService } from "~/services/interceptor.service" +import { useToast } from "~/composables/toast" +import { PasswordGrantTypeParams } from "@hoppscotch/data" + +const interceptorService = getService(InterceptorService) + +const PasswordFlowParamsSchema = PasswordGrantTypeParams.pick({ + authEndpoint: true, + clientID: true, + clientSecret: true, + scopes: true, + username: true, + password: true, +}).refine( + (params) => { + return ( + params.authEndpoint.length >= 1 && + params.clientID.length >= 1 && + params.clientSecret.length >= 1 && + params.username.length >= 1 && + params.password.length >= 1 && + (!params.scopes || params.scopes.length >= 1) + ) + }, + { + message: "Minimum length requirement not met for one or more parameters", + } +) + +export type PasswordFlowParams = z.infer + +export const getDefaultPasswordFlowParams = (): PasswordFlowParams => ({ + authEndpoint: "", + clientID: "", + clientSecret: "", + scopes: undefined, + username: "", + password: "", +}) + +const initPasswordOauthFlow = async ({ + password, + username, + clientID, + clientSecret, + scopes, + authEndpoint, +}: PasswordFlowParams) => { + const toast = useToast() + + const formData = new URLSearchParams() + formData.append("grant_type", "password") + formData.append("client_id", clientID) + formData.append("client_secret", clientSecret) + formData.append("username", username) + formData.append("password", password) + + if (scopes) { + formData.append("scope", scopes) + } + + const { response } = interceptorService.runRequest({ + url: authEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res) || res.right.status !== 200) { + toast.error("AUTH_TOKEN_REQUEST_FAILED") + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = new TextDecoder("utf-8") + .decode(res.right.data as any) + .replaceAll("\x00", "") + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + JSON.parse(responsePayload) + ) + + if (!parsedTokenResponse.success) { + toast.error("AUTH_TOKEN_REQUEST_INVALID_RESPONSE") + } + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +const handleRedirectForAuthCodeOauthFlow = async (localConfig: string) => { + // parse the query string + const params = new URLSearchParams(window.location.search) + + const code = params.get("code") + const state = params.get("state") + const error = params.get("error") + + if (error) { + return E.left("AUTH_SERVER_RETURNED_ERROR") + } + + if (!code) { + return E.left("AUTH_TOKEN_REQUEST_FAILED") + } + + const expectedSchema = z.object({ + state: z.string(), + tokenEndpoint: z.string(), + clientSecret: z.string(), + clientID: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse(JSON.parse(localConfig)) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_LOCAL_CONFIG") + } + + // check if the state matches + if (decodedLocalConfig.data.state !== state) { + return E.left("INVALID_STATE") + } + + // exchange the code for a token + const formData = new URLSearchParams() + formData.append("code", code) + formData.append("client_id", decodedLocalConfig.data.clientID) + formData.append("client_secret", decodedLocalConfig.data.clientSecret) + formData.append("redirect_uri", OauthAuthService.redirectURI) + + const { response } = interceptorService.runRequest({ + url: decodedLocalConfig.data.tokenEndpoint, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + data: formData.toString(), + }) + + const res = await response + + if (E.isLeft(res)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const responsePayload = decodeResponseAsJSON(res.right) + + if (E.isLeft(responsePayload)) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } + + const withAccessTokenSchema = z.object({ + access_token: z.string(), + }) + + const parsedTokenResponse = withAccessTokenSchema.safeParse( + responsePayload.right + ) + + return parsedTokenResponse.success + ? E.right(parsedTokenResponse.data) + : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) +} + +export default createFlowConfig( + "PASSWORD" as const, + PasswordFlowParamsSchema, + initPasswordOauthFlow, + handleRedirectForAuthCodeOauthFlow +) diff --git a/packages/hoppscotch-common/src/services/oauth/oauth.service.ts b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts new file mode 100644 index 00000000000..e0b01778bbd --- /dev/null +++ b/packages/hoppscotch-common/src/services/oauth/oauth.service.ts @@ -0,0 +1,124 @@ +import { Service } from "dioc" +import { PersistenceService } from "../persistence" +import { ZodType, z } from "zod" +import * as E from "fp-ts/Either" +import authCode, { AuthCodeOauthFlowParams } from "./flows/authCode" +import implicit, { ImplicitOauthFlowParams } from "./flows/implicit" +import { getService } from "~/modules/dioc" +import { HoppCollection } from "@hoppscotch/data" +import { TeamCollection } from "~/helpers/backend/graphql" + +export type PersistedOAuthConfig = { + source: "REST" | "GraphQL" + context?: { + type: "collection-properties" | "request-tab" + metadata: { + collection?: HoppCollection | TeamCollection + collectionID?: string + } + } + grant_type: string + fields?: (AuthCodeOauthFlowParams | ImplicitOauthFlowParams) & { + state: string + } + token?: string +} + +const persistenceService = getService(PersistenceService) + +export const grantTypesInvolvingRedirect = ["AUTHORIZATION_CODE", "IMPLICIT"] + +export const routeOAuthRedirect = async () => { + // get the temp data from the local storage + const localOAuthTempConfig = + persistenceService.getLocalConfig("oauth_temp_config") + + if (!localOAuthTempConfig) { + return E.left("INVALID_STATE") + } + + const expectedSchema = z.object({ + source: z.optional(z.string()), + grant_type: z.string(), + }) + + const decodedLocalConfig = expectedSchema.safeParse( + JSON.parse(localOAuthTempConfig) + ) + + if (!decodedLocalConfig.success) { + return E.left("INVALID_STATE") + } + + // route the request to the correct flow + const flowConfig = [authCode, implicit].find( + (flow) => flow.flow === decodedLocalConfig.data.grant_type + ) + + if (!flowConfig) { + return E.left("INVALID_STATE") + } + + return flowConfig?.onRedirectReceived(localOAuthTempConfig) +} + +export function createFlowConfig< + Flow extends string, + AuthParams extends Record, + InitFuncReturnObject extends Record, +>( + flow: Flow, + params: ZodType, + init: ( + params: AuthParams + ) => + | E.Either + | Promise> + | E.Either + | Promise>, + onRedirectReceived: (localConfig: string) => Promise< + E.Either< + string, + { + access_token: string + } + > + > +) { + return { + flow, + params, + init, + onRedirectReceived, + } +} + +export const decodeResponseAsJSON = (response: { data: any }) => { + try { + const responsePayload = new TextDecoder("utf-8") + .decode(response.data as any) + .replaceAll("\x00", "") + + return E.right(JSON.parse(responsePayload) as Record) + } catch (error) { + return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) + } +} + +export class OauthAuthService extends Service { + public static readonly ID = "OAUTH_AUTH_SERVICE" + + static redirectURI = `${window.location.origin}/oauth` + + constructor() { + super() + } +} + +export const generateRandomString = () => { + const length = 64 + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + const values = crypto.getRandomValues(new Uint8Array(length)) + return values.reduce((acc, x) => acc + possible[x % possible.length], "") +} 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 050237b2b58..12fe74e276c 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: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], @@ -50,7 +50,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ folders: [], requests: [ { - v: 2, + v: 3, name: "Echo test", url: "https://echo.hoppscotch.io/graphql", headers: [], @@ -138,7 +138,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [ preRequestScript: "", testScript: "", requestVariables: [], - v: "2", + v: "3", }, responseMeta: { duration: 807, statusCode: 200 }, star: false, @@ -150,7 +150,7 @@ export const GQL_HISTORY_MOCK: GQLHistoryEntry[] = [ { v: 1, request: { - v: 2, + v: 3, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", query: "query Request { url }", @@ -171,7 +171,7 @@ export const GQL_TAB_STATE_MOCK: PersistableTabState = { tabID: "5edbe8d4-65c9-4381-9354-5f1bf05d8ccc", doc: { request: { - v: 2, + v: 3, name: "Untitled", url: "https://echo.hoppscotch.io/graphql", headers: [], @@ -194,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa", doc: { request: { - v: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Echo test", params: [], diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts new file mode 100644 index 00000000000..8513bb83d32 --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/teamRequest.searcher.ts @@ -0,0 +1,140 @@ +import { Service } from "dioc" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from ".." +import { getI18n } from "~/modules/i18n" +import { Ref, computed, effectScope, markRaw, watch } from "vue" +import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service" +import { cloneDeep, debounce } from "lodash-es" +import IconFolder from "~icons/lucide/folder" +import { WorkspaceService } from "~/services/workspace.service" +import RESTTeamRequestEntry from "~/components/app/spotlight/entry/RESTTeamRequestEntry.vue" +import { RESTTabService } from "~/services/tab/rest" +import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { HoppRESTRequest } from "@hoppscotch/data" + +export class TeamsSpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "TEAMS_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public searcherID = "teams" + public searcherSectionTitle = this.t("team.search_title") + + private readonly spotlight = this.bind(SpotlightService) + + private readonly teamsSearch = this.bind(TeamSearchService) + + private readonly workspaceService = this.bind(WorkspaceService) + + private readonly tabs = this.bind(RESTTabService) + + constructor() { + super() + + this.spotlight.registerSearcher(this) + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const isTeamWorkspace = computed( + () => this.workspaceService.currentWorkspace.value.type === "team" + ) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + const debouncedSearch = debounce(this.teamsSearch.searchTeams, 400) + + watch( + query, + (query) => { + if (this.workspaceService.currentWorkspace.value.type === "team") { + const teamID = this.workspaceService.currentWorkspace.value.teamID + debouncedSearch(query, teamID)?.catch((_) => {}) + } + }, + { + immediate: true, + } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + } + + const resultObj = computed(() => { + return isTeamWorkspace.value + ? { + loading: this.teamsSearch.teamsSearchResultsLoading.value, + results: + this.teamsSearch.teamsSearchResultsFormattedForSpotlight.value.map( + (result) => ({ + id: result.request.id, + icon: markRaw(IconFolder), + score: 1, // make a better scoring system for this + text: { + type: "custom", + component: markRaw(RESTTeamRequestEntry), + componentProps: { + collectionTitles: result.collectionTitles, + request: result.request, + }, + }, + }) + ), + } + : { + loading: false, + results: [], + } + }) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + let inheritedProperties: HoppInheritedProperty | undefined = undefined + + const selectedRequest = this.teamsSearch.searchResultsRequests[result.id] + + if (!selectedRequest) return + + const collectionID = result.id + + if (!collectionID) return + + inheritedProperties = + this.teamsSearch.cascadeParentCollectionForHeaderAuthForSearchResults( + collectionID + ) + + const possibleTab = this.tabs.getTabRefWithSaveContext({ + originLocation: "team-collection", + requestID: result.id, + }) + + if (possibleTab) { + this.tabs.setActiveTab(possibleTab.value.id) + } else { + this.tabs.createNewTab({ + request: cloneDeep(selectedRequest.request as HoppRESTRequest), + isDirty: false, + saveContext: { + originLocation: "team-collection", + requestID: selectedRequest.id, + collectionID: selectedRequest.collectionID, + }, + inheritedProperties: inheritedProperties, + }) + } + } +} diff --git a/packages/hoppscotch-data/package.json b/packages/hoppscotch-data/package.json index 92b029e0c53..d6fa1fab3b7 100644 --- a/packages/hoppscotch-data/package.json +++ b/packages/hoppscotch-data/package.json @@ -10,6 +10,7 @@ "dist/*" ], "scripts": { + "dev": "vite build --watch", "build:code": "vite build", "build:decl": "tsc --project tsconfig.decl.json", "build": "pnpm run build:code && pnpm run build:decl", @@ -46,4 +47,4 @@ "verzod": "0.2.2", "zod": "3.22.4" } -} \ No newline at end of file +} diff --git a/packages/hoppscotch-data/src/collection/index.ts b/packages/hoppscotch-data/src/collection/index.ts index 47e5180c108..7ed072c94dc 100644 --- a/packages/hoppscotch-data/src/collection/index.ts +++ b/packages/hoppscotch-data/src/collection/index.ts @@ -8,8 +8,7 @@ import { translateToNewRequest } from "../rest" import { translateToGQLRequest } from "../graphql" const versionedObject = z.object({ - // v is a stringified number - v: z.string().regex(/^\d+$/).transform(Number), + v: z.number(), }) export const HoppCollection = createVersionedEntity({ @@ -26,7 +25,7 @@ export const HoppCollection = createVersionedEntity({ // For V1 we have to check the schema const result = V1_VERSION.schema.safeParse(data) - return result.success ? 0 : null + return result.success ? 1 : null }, }) diff --git a/packages/hoppscotch-data/src/graphql/index.ts b/packages/hoppscotch-data/src/graphql/index.ts index ab58235418f..c314a3437b5 100644 --- a/packages/hoppscotch-data/src/graphql/index.ts +++ b/packages/hoppscotch-data/src/graphql/index.ts @@ -2,29 +2,32 @@ import { InferredEntity, createVersionedEntity } from "verzod" import { z } from "zod" import V1_VERSION from "./v/1" import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" export { GQLHeader } from "./v/1" export { - HoppGQLAuth, HoppGQLAuthAPIKey, HoppGQLAuthBasic, HoppGQLAuthBearer, HoppGQLAuthNone, - HoppGQLAuthOAuth2, HoppGQLAuthInherit, } from "./v/2" -export const GQL_REQ_SCHEMA_VERSION = 2 +export { HoppGQLAuth } from "./v/3" +export { HoppGQLAuthOAuth2 } from "./v/3" + +export const GQL_REQ_SCHEMA_VERSION = 3 const versionedObject = z.object({ v: z.number(), }) export const HoppGQLRequest = createVersionedEntity({ - latestVersion: 2, + latestVersion: 3, versionMap: { 1: V1_VERSION, 2: V2_VERSION, + 3: V3_VERSION, }, getVersion(x) { const result = versionedObject.safeParse(x) diff --git a/packages/hoppscotch-data/src/graphql/v/2.ts b/packages/hoppscotch-data/src/graphql/v/2.ts index 50ef6373f8b..e4392a31418 100644 --- a/packages/hoppscotch-data/src/graphql/v/2.ts +++ b/packages/hoppscotch-data/src/graphql/v/2.ts @@ -71,7 +71,7 @@ export const HoppGQLAuth = z export type HoppGQLAuth = z.infer -const V2_SCHEMA = z.object({ +export const V2_SCHEMA = z.object({ id: z.optional(z.string()), v: z.literal(2), diff --git a/packages/hoppscotch-data/src/graphql/v/3.ts b/packages/hoppscotch-data/src/graphql/v/3.ts new file mode 100644 index 00000000000..60fb87ecf1d --- /dev/null +++ b/packages/hoppscotch-data/src/graphql/v/3.ts @@ -0,0 +1,77 @@ +import { z } from "zod" + +import { defineVersion } from "verzod" + +import { HoppRESTAuthOAuth2 } from "../../rest" +import { + HoppGQLAuthAPIKey, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthInherit, + HoppGQLAuthNone, + V2_SCHEMA, +} from "./2" + +export { HoppRESTAuthOAuth2 as HoppGQLAuthOAuth2 } from "../../rest" + +export type HoppGqlAuthOAuth2 = z.infer + +export const HoppGQLAuth = z + .discriminatedUnion("authType", [ + HoppGQLAuthNone, + HoppGQLAuthInherit, + HoppGQLAuthBasic, + HoppGQLAuthBearer, + HoppGQLAuthAPIKey, + HoppRESTAuthOAuth2, // both rest and gql have the same auth type for oauth2 + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppGQLAuth = z.infer + +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal(3), + auth: HoppGQLAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: 3 as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 72390ec5b6f..8fea04d40e3 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -4,32 +4,39 @@ import cloneDeep from "lodash/cloneDeep" import V0_VERSION from "./v/0" import V1_VERSION from "./v/1" import V2_VERSION from "./v/2" +import V3_VERSION from "./v/3" import { createVersionedEntity, InferredEntity } from "verzod" import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq" -import { - HoppRESTAuth, - HoppRESTReqBody, - HoppRESTHeaders, - HoppRESTParams, -} from "./v/1" + +import { HoppRESTReqBody, HoppRESTHeaders, HoppRESTParams } from "./v/1" + +import { HoppRESTAuth } from "./v/3" import { HoppRESTRequestVariables } from "./v/2" import { z } from "zod" export * from "./content-types" + export { FormDataKeyValue, HoppRESTReqBodyFormData, - HoppRESTAuth, HoppRESTAuthAPIKey, HoppRESTAuthBasic, HoppRESTAuthInherit, HoppRESTAuthBearer, HoppRESTAuthNone, - HoppRESTAuthOAuth2, HoppRESTReqBody, HoppRESTHeaders, } from "./v/1" +export { + HoppRESTAuth, + HoppRESTAuthOAuth2, + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + ImplicitOauthFlowParams, + PasswordGrantTypeParams, +} from "./v/3" + export { HoppRESTRequestVariables } from "./v/2" const versionedObject = z.object({ @@ -38,11 +45,12 @@ const versionedObject = z.object({ }) export const HoppRESTRequest = createVersionedEntity({ - latestVersion: 2, + latestVersion: 3, versionMap: { 0: V0_VERSION, 1: V1_VERSION, 2: V2_VERSION, + 3: V3_VERSION, }, getVersion(data) { // For V1 onwards we have the v string storing the number @@ -84,7 +92,7 @@ const HoppRESTRequestEq = Eq.struct({ ), }) -export const RESTReqSchemaVersion = "2" +export const RESTReqSchemaVersion = "3" export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number] @@ -179,7 +187,7 @@ export function makeRESTRequest( export function getDefaultRESTRequest(): HoppRESTRequest { return { - v: "2", + v: "3", endpoint: "https://echo.hoppscotch.io", name: "Untitled", params: [], diff --git a/packages/hoppscotch-data/src/rest/v/2.ts b/packages/hoppscotch-data/src/rest/v/2.ts index 894a3727113..db49ebfb4b1 100644 --- a/packages/hoppscotch-data/src/rest/v/2.ts +++ b/packages/hoppscotch-data/src/rest/v/2.ts @@ -18,7 +18,7 @@ export const HoppRESTRequestVariables = z.array( export type HoppRESTRequestVariables = z.infer -const V2_SCHEMA = V1_SCHEMA.extend({ +export const V2_SCHEMA = V1_SCHEMA.extend({ v: z.literal("2"), requestVariables: HoppRESTRequestVariables, }) diff --git a/packages/hoppscotch-data/src/rest/v/3.ts b/packages/hoppscotch-data/src/rest/v/3.ts new file mode 100644 index 00000000000..2a2a7bb36bc --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/3.ts @@ -0,0 +1,127 @@ +import { z } from "zod" +import { + HoppRESTAuthAPIKey, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthInherit, + HoppRESTAuthNone, +} from "./1" +import { V2_SCHEMA } from "./2" + +import { defineVersion } from "verzod" + +export const AuthCodeGrantTypeParams = z.object({ + grantType: z.literal("AUTHORIZATION_CODE"), + authEndpoint: z.string().trim(), + tokenEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), + isPKCE: z.boolean(), + codeVerifierMethod: z + .union([z.literal("plain"), z.literal("S256")]) + .optional(), +}) + +export const ClientCredentialsGrantTypeParams = z.object({ + grantType: z.literal("CLIENT_CREDENTIALS"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const PasswordGrantTypeParams = z.object({ + grantType: z.literal("PASSWORD"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + clientSecret: z.string().trim(), + scopes: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + token: z.string().catch(""), +}) + +export const ImplicitOauthFlowParams = z.object({ + grantType: z.literal("IMPLICIT"), + authEndpoint: z.string().trim(), + clientID: z.string().trim(), + scopes: z.string().trim().optional(), + token: z.string().catch(""), +}) + +export const HoppRESTAuthOAuth2 = z.object({ + authType: z.literal("oauth-2"), + grantTypeInfo: z.discriminatedUnion("grantType", [ + AuthCodeGrantTypeParams, + ClientCredentialsGrantTypeParams, + PasswordGrantTypeParams, + ImplicitOauthFlowParams, + ]), + addTo: z.enum(["HEADERS", "QUERY_PARAMS"]).catch("HEADERS"), +}) + +export type HoppRESTAuthOAuth2 = z.infer + +export const HoppRESTAuth = z + .discriminatedUnion("authType", [ + HoppRESTAuthNone, + HoppRESTAuthInherit, + HoppRESTAuthBasic, + HoppRESTAuthBearer, + HoppRESTAuthOAuth2, + HoppRESTAuthAPIKey, + ]) + .and( + z.object({ + authActive: z.boolean(), + }) + ) + +export type HoppRESTAuth = z.infer + +// V2_SCHEMA has one change in HoppRESTAuthOAuth2, we'll add the grant_type field +export const V3_SCHEMA = V2_SCHEMA.extend({ + v: z.literal("3"), + auth: HoppRESTAuth, +}) + +export default defineVersion({ + initial: false, + schema: V3_SCHEMA, + up(old: z.infer) { + if (old.auth.authType === "oauth-2") { + const { token, accessTokenURL, scope, clientID, authURL } = old.auth + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + authType: "oauth-2" as const, + grantTypeInfo: { + grantType: "AUTHORIZATION_CODE" as const, + authEndpoint: authURL, + tokenEndpoint: accessTokenURL, + clientID: clientID, + clientSecret: "", + scopes: scope, + isPKCE: false, + token, + }, + addTo: "HEADERS" as const, + }, + } + } + + return { + ...old, + v: "3" as const, + auth: { + ...old.auth, + }, + } + }, +}) diff --git a/packages/hoppscotch-selfhost-web/src/platform/auth/auth.platform.ts b/packages/hoppscotch-selfhost-web/src/platform/auth/auth.platform.ts index c6708bc8faf..5c43adc00b2 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/auth/auth.platform.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/auth/auth.platform.ts @@ -211,6 +211,13 @@ export const def: AuthPlatformDef = { } }, + axiosPlatformConfig() { + return { + // for including cookies in the request + withCredentials: true, + } + }, + /** * it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js * hence just returning if the currentUser$ has a value associated with it diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index b53af9d2716..ad321012294 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -253,7 +253,6 @@ }, "users": { "admin": "Admin", - "admin_email": "Admin Email", "admin_id": "Admin ID", "cancel": "Cancel", "created_on": "Created On", @@ -270,6 +269,7 @@ "invalid_user": "Invalid User", "invite_load_list_error": "Unable to Load Invited Users List", "invite_user": "Invite User", + "invited_by": "Invited By", "invited_on": "Invited On", "invited_users": "Invited Users", "invitee_email": "Invitee Email", diff --git a/packages/hoppscotch-sh-admin/src/components.d.ts b/packages/hoppscotch-sh-admin/src/components.d.ts index c437ffcc317..eaaf88bfeca 100644 --- a/packages/hoppscotch-sh-admin/src/components.d.ts +++ b/packages/hoppscotch-sh-admin/src/components.d.ts @@ -51,7 +51,6 @@ declare module '@vue/runtime-core' { UsersDetails: typeof import('./components/users/Details.vue')['default'] UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'] UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'] - UsersTable: typeof import('./components/users/Table.vue')['default'] } } diff --git a/packages/hoppscotch-sh-admin/src/pages/users/index.vue b/packages/hoppscotch-sh-admin/src/pages/users/index.vue index 803583a4965..b8b7e66c32e 100644 --- a/packages/hoppscotch-sh-admin/src/pages/users/index.vue +++ b/packages/hoppscotch-sh-admin/src/pages/users/index.vue @@ -20,7 +20,7 @@ />
-
+
format(new Date(date), 'dd-MM-yyyy'); @@ -130,9 +130,8 @@ const { fetching, error, data } = useQuery({ query: InvitedUsersDocument }); // Table Headings const headings = [ - { key: 'adminUid', label: t('users.admin_id') }, - { key: 'adminEmail', label: t('users.admin_email') }, { key: 'inviteeEmail', label: t('users.invitee_email') }, + { key: 'adminEmail', label: t('users.invited_by') }, { key: 'invitedOn', label: t('users.invited_on') }, { key: 'action', label: 'Action' }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c50167b89c..0c943dc3331 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24543,3 +24543,4 @@ packages: /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false + \ No newline at end of file