Compare commits

..

17 Commits

Author SHA1 Message Date
jamesgeorge007
ce9ea0cbc7 chore: port fix to selfhost-desktop 2024-03-11 22:58:31 +05:30
nivedin
8844d52f5f refactor: update collection import and transform collection update 2024-03-11 22:58:31 +05:30
nivedin
772c1e65e5 fix: requests failed to upgrade version while syncing 2024-03-11 22:58:31 +05:30
James George
dce396c164 chore: bump codemirror dependencies (#3888) 2024-03-11 14:21:39 +05:30
Nivedin
07e8af7947 refactor: update team nomenclature (#3880)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-08 23:54:32 +05:30
Joel Jacob Stephen
e69d5a6253 feat(sh-admin): introducing additional SSO related server configurations to dashboard (#3737)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-08 15:18:53 +05:30
Akash K
6d66d12a9e feat: common changes for site protection (#3878) 2024-03-07 23:43:20 +05:30
James George
439cd82c88 chore: pin dependencies across packages (#3876) 2024-03-07 23:37:48 +05:30
Akash K
6dbaf524ce feat: use tags as folders when importing from openapi (#3846) 2024-03-07 19:55:46 +05:30
Andrew Bastin
68e439d1a4 chore: bump version to 2024.3.0 2024-03-07 19:22:46 +05:30
Nivedin
8deba7a28e fix: context menu bug and incorrect position (#3874) 2024-03-07 17:59:06 +05:30
Nivedin
7ec8659381 feat: request variables (#3825)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-03-07 12:50:44 +05:30
Mir Arif Hasan
3611cac241 feat(backend): sso callback url and scope added in infra-config (#3718) 2024-03-07 12:07:51 +05:30
Joel Jacob Stephen
919579b1da feat(sh-admin): introducing data analytics and newsletter configurations (#3845)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-03-06 20:06:48 +05:30
Nivedin
4798d7bbbd refactor: remove restore tab popup and its functionalities (#3867) 2024-03-05 18:14:41 +05:30
Balu Babu
a0c6b22641 feat: full text search for TeamCollections and TeamRequests (#3857)
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-03-05 18:05:58 +05:30
James George
de8929ab18 feat(common): support simultaneous imports of collections and environment files (#3719) 2024-03-05 17:49:01 +05:30
149 changed files with 5992 additions and 7905 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
shamefully-hoist=false shamefully-hoist=false
save-prefix=''

View File

@@ -112,17 +112,17 @@ services:
build: build:
dockerfile: packages/hoppscotch-backend/Dockerfile dockerfile: packages/hoppscotch-backend/Dockerfile
context: . context: .
target: prod target: dev
env_file: env_file:
- ./.env - ./.env
restart: always restart: always
environment: environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000 - PORT=3000
volumes: volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target. # Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app - ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/ - /usr/src/app/node_modules/
depends_on: depends_on:
hoppscotch-db: hoppscotch-db:

View File

@@ -23,13 +23,13 @@
"./packages/*" "./packages/*"
], ],
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^16.2.3", "@commitlint/cli": "16.3.0",
"@commitlint/config-conventional": "^16.2.1", "@commitlint/config-conventional": "16.2.4",
"@hoppscotch/ui": "^0.1.0", "@hoppscotch/ui": "0.1.0",
"@types/node": "17.0.27", "@types/node": "17.0.27",
"cross-env": "^7.0.3", "cross-env": "7.0.3",
"http-server": "^14.1.1", "http-server": "14.1.1",
"husky": "^7.0.4", "husky": "7.0.4",
"lint-staged": "12.4.0" "lint-staged": "12.4.0"
}, },
"pnpm": { "pnpm": {
@@ -37,7 +37,7 @@
"vue": "3.3.9" "vue": "3.3.9"
}, },
"packageExtensions": { "packageExtensions": {
"httpsnippet@^3.0.1": { "httpsnippet@3.0.1": {
"peerDependencies": { "peerDependencies": {
"ajv": "6.12.3" "ajv": "6.12.3"
} }

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@codemirror/language": "6.9.3", "@codemirror/language": "6.10.1",
"@lezer/highlight": "1.2.0", "@lezer/highlight": "1.2.0",
"@lezer/lr": "^1.3.14" "@lezer/lr": "1.3.14"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.5.1", "@lezer/generator": "1.5.1",
"mocha": "^9.2.2", "mocha": "9.2.2",
"rollup": "^3.29.3", "rollup": "3.29.4",
"rollup-plugin-dts": "^6.0.2", "rollup-plugin-dts": "6.0.2",
"rollup-plugin-ts": "^3.4.5", "rollup-plugin-ts": "3.4.5",
"typescript": "^5.2.2" "typescript": "5.2.2"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoppscotch-backend", "name": "hoppscotch-backend",
"version": "2023.12.6", "version": "2024.3.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -24,83 +24,83 @@
"do-test": "pnpm run test" "do-test": "pnpm run test"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.9.4", "@apollo/server": "4.9.5",
"@nestjs-modules/mailer": "^1.9.1", "@nestjs-modules/mailer": "1.9.1",
"@nestjs/apollo": "^12.0.9", "@nestjs/apollo": "12.0.9",
"@nestjs/common": "^10.2.6", "@nestjs/common": "10.2.7",
"@nestjs/config": "^3.1.1", "@nestjs/config": "3.1.1",
"@nestjs/core": "^10.2.6", "@nestjs/core": "10.2.7",
"@nestjs/graphql": "^12.0.9", "@nestjs/graphql": "12.0.9",
"@nestjs/jwt": "^10.1.1", "@nestjs/jwt": "10.1.1",
"@nestjs/passport": "^10.0.2", "@nestjs/passport": "10.0.2",
"@nestjs/platform-express": "^10.2.6", "@nestjs/platform-express": "10.2.7",
"@nestjs/schedule": "^4.0.1", "@nestjs/schedule": "4.0.1",
"@nestjs/throttler": "^5.0.0", "@nestjs/throttler": "5.0.1",
"@prisma/client": "^5.8.0", "@prisma/client": "5.8.1",
"argon2": "^0.30.3", "argon2": "0.30.3",
"bcrypt": "^5.1.0", "bcrypt": "5.1.0",
"cookie": "^0.5.0", "cookie": "0.5.0",
"cookie-parser": "^1.4.6", "cookie-parser": "1.4.6",
"cron": "^3.1.6", "cron": "3.1.6",
"express": "^4.17.1", "express": "4.18.2",
"express-session": "^1.17.3", "express-session": "1.17.3",
"fp-ts": "^2.13.1", "fp-ts": "2.13.1",
"graphql": "^16.8.1", "graphql": "16.8.1",
"graphql-query-complexity": "^0.12.0", "graphql-query-complexity": "0.12.0",
"graphql-redis-subscriptions": "^2.6.0", "graphql-redis-subscriptions": "2.6.0",
"graphql-subscriptions": "^2.0.0", "graphql-subscriptions": "2.0.0",
"handlebars": "^4.7.7", "handlebars": "4.7.7",
"io-ts": "^2.2.16", "io-ts": "2.2.16",
"luxon": "^3.2.1", "luxon": "3.2.1",
"nodemailer": "^6.9.1", "nodemailer": "6.9.1",
"passport": "^0.6.0", "passport": "0.6.0",
"passport-github2": "^0.1.12", "passport-github2": "0.1.12",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "4.0.1",
"passport-local": "^1.0.0", "passport-local": "1.0.0",
"passport-microsoft": "^1.0.0", "passport-microsoft": "1.0.0",
"posthog-node": "^3.6.3", "posthog-node": "3.6.3",
"prisma": "^5.8.0", "prisma": "5.8.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "0.1.13",
"rimraf": "^3.0.2", "rimraf": "3.0.2",
"rxjs": "^7.6.0" "rxjs": "7.6.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.1.18", "@nestjs/cli": "10.2.1",
"@nestjs/schematics": "^10.0.2", "@nestjs/schematics": "10.0.3",
"@nestjs/testing": "^10.2.6", "@nestjs/testing": "10.2.7",
"@relmify/jest-fp-ts": "^2.0.2", "@relmify/jest-fp-ts": "2.0.2",
"@types/argon2": "^0.15.0", "@types/argon2": "0.15.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "5.0.0",
"@types/cookie": "^0.5.1", "@types/cookie": "0.5.1",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "1.4.3",
"@types/express": "^4.17.14", "@types/express": "4.17.14",
"@types/jest": "^29.4.0", "@types/jest": "29.4.0",
"@types/luxon": "^3.2.0", "@types/luxon": "3.2.0",
"@types/node": "^18.11.10", "@types/node": "18.11.10",
"@types/nodemailer": "^6.4.7", "@types/nodemailer": "6.4.7",
"@types/passport-github2": "^1.2.5", "@types/passport-github2": "1.2.5",
"@types/passport-google-oauth20": "^2.0.11", "@types/passport-google-oauth20": "2.0.11",
"@types/passport-jwt": "^3.0.8", "@types/passport-jwt": "3.0.8",
"@types/passport-microsoft": "^0.0.0", "@types/passport-microsoft": "0.0.0",
"@types/supertest": "^2.0.12", "@types/supertest": "2.0.12",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "5.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "5.45.0",
"cross-env": "^7.0.3", "cross-env": "7.0.3",
"eslint": "^8.29.0", "eslint": "8.29.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "4.2.1",
"jest": "^29.4.1", "jest": "29.4.1",
"jest-mock-extended": "^3.0.1", "jest-mock-extended": "3.0.1",
"jwt": "link:@types/nestjs/jwt", "jwt": "link:@types/nestjs/jwt",
"prettier": "^2.8.4", "prettier": "2.8.4",
"source-map-support": "^0.5.21", "source-map-support": "0.5.21",
"supertest": "^6.3.2", "supertest": "6.3.2",
"ts-jest": "29.0.5", "ts-jest": "29.0.5",
"ts-loader": "^9.4.2", "ts-loader": "9.4.2",
"ts-node": "^10.9.1", "ts-node": "10.9.1",
"tsconfig-paths": "4.1.1", "tsconfig-paths": "4.1.1",
"typescript": "^4.9.3" "typescript": "4.9.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -121,4 +121,4 @@
"^src/(.*)$": "<rootDir>/$1" "^src/(.*)$": "<rootDir>/$1"
} }
} }
} }

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE
"TeamCollection"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
-- AlterTable
ALTER TABLE
"TeamRequest"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
-- CreateIndex
CREATE INDEX "TeamCollection_textSearch_idx" ON "TeamCollection" USING GIN (titleSearch);
-- CreateIndex
CREATE INDEX "TeamRequest_textSearch_idx" ON "TeamRequest" USING GIN (titleSearch);

View File

@@ -41,31 +41,31 @@ model TeamInvitation {
} }
model TeamCollection { model TeamCollection {
id String @id @default(cuid()) id String @id @default(cuid())
parentID String? parentID String?
data Json? data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent") children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[] requests TeamRequest[]
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model TeamRequest { model TeamRequest {
id String @id @default(cuid()) id String @id @default(cuid())
collectionID String collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String title String
request Json request Json
orderIndex Int orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3) updatedOn DateTime @updatedAt @db.Timestamp(3)
} }
model Shortcode { model Shortcode {

View File

@@ -32,7 +32,7 @@ import {
EnableAndDisableSSOArgs, EnableAndDisableSSOArgs,
InfraConfigArgs, InfraConfigArgs,
} from 'src/infra-config/input-args'; } from 'src/infra-config/input-args';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from 'src/infra-config/helper'; import { ServiceStatus } from 'src/infra-config/helper';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@@ -274,10 +274,10 @@ export class InfraResolver {
async infraConfigs( async infraConfigs(
@Args({ @Args({
name: 'configNames', name: 'configNames',
type: () => [InfraConfigEnumForClient], type: () => [InfraConfigEnum],
description: 'Configs to fetch', description: 'Configs to fetch',
}) })
names: InfraConfigEnumForClient[], names: InfraConfigEnum[],
) { ) {
const infraConfigs = await this.infraConfigService.getMany(names); const infraConfigs = await this.infraConfigService.getMany(names);
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left); if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);

View File

@@ -18,12 +18,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import { import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
@@ -31,6 +26,7 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' }) @Controller({ path: 'auth', version: '1' })

View File

@@ -12,7 +12,10 @@ import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy'; import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper'; import { AuthProvider, authProviderCheck } from './helper';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper'; import {
isInfraConfigTablePopulated,
loadInfraConfiguration,
} from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module'; import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({ @Module({
@@ -34,6 +37,11 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
}) })
export class AuthModule { export class AuthModule {
static async register() { static async register() {
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
if (!isInfraConfigPopulated) {
return { module: AuthModule };
}
const env = await loadInfraConfiguration(); const env = await loadInfraConfiguration();
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS; const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;

View File

@@ -24,7 +24,7 @@ import {
RefreshTokenPayload, RefreshTokenPayload,
} from 'src/types/AuthTokens'; } from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthError } from 'src/types/AuthError'; import { RESTError } from 'src/types/RESTError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser'; import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client'; import { VerificationToken } from '@prisma/client';
import { Origin } from './helper'; import { Origin } from './helper';
@@ -117,7 +117,7 @@ export class AuthService {
userUid, userUid,
); );
if (E.isLeft(updatedUser)) if (E.isLeft(updatedUser))
return E.left(<AuthError>{ return E.left(<RESTError>{
message: updatedUser.left, message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });
@@ -255,7 +255,7 @@ export class AuthService {
*/ */
async verifyMagicLinkTokens( async verifyMagicLinkTokens(
magicLinkIDTokens: VerifyMagicDto, magicLinkIDTokens: VerifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> { ): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
const passwordlessTokens = await this.validatePasswordlessTokens( const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens, magicLinkIDTokens,
); );
@@ -373,7 +373,7 @@ export class AuthService {
if (usersCount === 1) { if (usersCount === 1) {
const elevatedUser = await this.usersService.makeAdmin(user.uid); const elevatedUser = await this.usersService.makeAdmin(user.uid);
if (E.isLeft(elevatedUser)) if (E.isLeft(elevatedUser))
return E.left(<AuthError>{ return E.left(<RESTError>{
message: elevatedUser.left, message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate { export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate { export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable() @Injectable()
export class MicrosoftSSOGuard export class MicrosoftSSOGuard

View File

@@ -1,6 +1,5 @@
import { HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens'; import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
@@ -25,15 +24,6 @@ export enum AuthProvider {
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
} }
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: AuthError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/** /**
* Sets and returns the cookies in the response object on successful authentication * Sets and returns the cookies in the response object on successful authentication
* @param res Express Response Object * @param res Express Response Object

View File

@@ -17,8 +17,8 @@ export class GithubStrategy extends PassportStrategy(Strategy) {
super({ super({
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'), clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'), clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
callbackURL: configService.get('GITHUB_CALLBACK_URL'), callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
scope: [configService.get('GITHUB_SCOPE')], scope: [configService.get('INFRA.GITHUB_SCOPE')],
store: true, store: true,
}); });
} }

View File

@@ -17,8 +17,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
super({ super({
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'), clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'), clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('GOOGLE_CALLBACK_URL'), callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
scope: configService.get('GOOGLE_SCOPE').split(','), scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
passReqToCallback: true, passReqToCallback: true,
store: true, store: true,
}); });

View File

@@ -17,9 +17,9 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
super({ super({
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'), clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'), clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
callbackURL: configService.get('MICROSOFT_CALLBACK_URL'), callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
scope: [configService.get('MICROSOFT_SCOPE')], scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
tenant: configService.get('MICROSOFT_TENANT'), tenant: configService.get('INFRA.MICROSOFT_TENANT'),
store: true, store: true,
}); });
} }

View File

@@ -228,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
export const TEAM_COL_SAME_NEXT_COLL = export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same'; 'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/** /**
* Team Collection Re-Ordering Failed * Team Collection Re-Ordering Failed
* (TeamCollectionService) * (TeamCollectionService)
@@ -283,6 +289,13 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
export const TEAM_COLL_DATA_INVALID = export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const; 'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/** /**
* Tried to perform an action on a request that doesn't accept their member role level * Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard) * (GqlRequestTeamMemberGuard)
@@ -308,6 +321,19 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
*/ */
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/** /**
* No Postmark Sender Email defined * No Postmark Sender Email defined
* (AuthService) * (AuthService)
@@ -705,6 +731,13 @@ export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED = export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
'infra_config/service_not_configured' as const; 'infra_config/service_not_configured' as const;
/**
* Infra Config update/fetch operation not allowed
* (InfraConfigService)
*/
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
'infra_config/operation_not_allowed';
/** /**
* Error message for when the database table does not exist * Error message for when the database table does not exist
* (InfraConfigService) * (InfraConfigService)

View File

@@ -1,5 +1,8 @@
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
import { AUTH_PROVIDER_NOT_CONFIGURED } from 'src/errors'; import {
AUTH_PROVIDER_NOT_CONFIGURED,
DATABASE_TABLE_NOT_EXIST,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
@@ -14,14 +17,21 @@ const AuthProviderConfigurations = {
[AuthProvider.GOOGLE]: [ [AuthProvider.GOOGLE]: [
InfraConfigEnum.GOOGLE_CLIENT_ID, InfraConfigEnum.GOOGLE_CLIENT_ID,
InfraConfigEnum.GOOGLE_CLIENT_SECRET, InfraConfigEnum.GOOGLE_CLIENT_SECRET,
InfraConfigEnum.GOOGLE_CALLBACK_URL,
InfraConfigEnum.GOOGLE_SCOPE,
], ],
[AuthProvider.GITHUB]: [ [AuthProvider.GITHUB]: [
InfraConfigEnum.GITHUB_CLIENT_ID, InfraConfigEnum.GITHUB_CLIENT_ID,
InfraConfigEnum.GITHUB_CLIENT_SECRET, InfraConfigEnum.GITHUB_CLIENT_SECRET,
InfraConfigEnum.GITHUB_CALLBACK_URL,
InfraConfigEnum.GITHUB_SCOPE,
], ],
[AuthProvider.MICROSOFT]: [ [AuthProvider.MICROSOFT]: [
InfraConfigEnum.MICROSOFT_CLIENT_ID, InfraConfigEnum.MICROSOFT_CLIENT_ID,
InfraConfigEnum.MICROSOFT_CLIENT_SECRET, InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
], ],
[AuthProvider.EMAIL]: [ [AuthProvider.EMAIL]: [
InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_SMTP_URL,
@@ -54,6 +64,125 @@ export async function loadInfraConfiguration() {
} }
} }
/**
* Read the default values from .env file and return them as an array
* @returns Array of default infra configs
*/
export async function getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
const prisma = new PrismaService();
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
value: process.env.GOOGLE_CALLBACK_URL,
},
{
name: InfraConfigEnum.GOOGLE_SCOPE,
value: process.env.GOOGLE_SCOPE,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
value: process.env.GITHUB_CALLBACK_URL,
},
{
name: InfraConfigEnum.GITHUB_SCOPE,
value: process.env.GITHUB_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
value: process.env.MICROSOFT_CALLBACK_URL,
},
{
name: InfraConfigEnum.MICROSOFT_SCOPE,
value: process.env.MICROSOFT_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_TENANT,
value: process.env.MICROSOFT_TENANT,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(),
},
{
name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generateAnalyticsUserId(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
}
/**
* Verify if 'infra_config' table is loaded with all entries
* @returns boolean
*/
export async function isInfraConfigTablePopulated(): Promise<boolean> {
const prisma = new PrismaService();
try {
const dbInfraConfigs = await prisma.infraConfig.findMany();
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const propsRemainingToInsert = infraConfigDefaultObjs.filter(
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
);
if (propsRemainingToInsert.length > 0) {
console.log(
'Infra Config table is not populated with all entries. Populating now...',
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/** /**
* Stop the app after 5 seconds * Stop the app after 5 seconds
* (Docker will re-start the app) * (Docker will re-start the app)

View File

@@ -4,9 +4,9 @@ import { InfraConfigService } from './infra-config.service';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard'; import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
import { throwHTTPErr } from 'src/auth/helper'; import { RESTError } from 'src/types/RESTError';
import { AuthError } from 'src/types/AuthError'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig'; import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard) @UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'site', version: '1' }) @Controller({ path: 'site', version: '1' })
@@ -17,11 +17,11 @@ export class SiteController {
@UseGuards(JwtAuthGuard, RESTAdminGuard) @UseGuards(JwtAuthGuard, RESTAdminGuard)
async fetchSetupInfo() { async fetchSetupInfo() {
const status = await this.infraConfigService.get( const status = await this.infraConfigService.get(
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP, InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
); );
if (E.isLeft(status)) if (E.isLeft(status))
throwHTTPErr(<AuthError>{ throwHTTPErr(<RESTError>{
message: status.left, message: status.left,
statusCode: HttpStatus.NOT_FOUND, statusCode: HttpStatus.NOT_FOUND,
}); });
@@ -32,13 +32,13 @@ export class SiteController {
@UseGuards(JwtAuthGuard, RESTAdminGuard) @UseGuards(JwtAuthGuard, RESTAdminGuard)
async setSetupAsComplete() { async setSetupAsComplete() {
const res = await this.infraConfigService.update( const res = await this.infraConfigService.update(
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP, InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
false.toString(), false.toString(),
false, false,
); );
if (E.isLeft(res)) if (E.isLeft(res))
throwHTTPErr(<AuthError>{ throwHTTPErr(<RESTError>{
message: res.left, message: res.left,
statusCode: HttpStatus.FORBIDDEN, statusCode: HttpStatus.FORBIDDEN,
}); });

View File

@@ -1,6 +1,6 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper'; import { ServiceStatus } from './helper';
@ObjectType() @ObjectType()
@@ -8,7 +8,7 @@ export class InfraConfig {
@Field({ @Field({
description: 'Infra Config Name', description: 'Infra Config Name',
}) })
name: InfraConfigEnumForClient; name: InfraConfigEnum;
@Field({ @Field({
description: 'Infra Config Value', description: 'Infra Config Value',
@@ -16,7 +16,7 @@ export class InfraConfig {
value: string; value: string;
} }
registerEnumType(InfraConfigEnumForClient, { registerEnumType(InfraConfigEnum, {
name: 'InfraConfigEnum', name: 'InfraConfigEnum',
}); });

View File

@@ -1,13 +1,16 @@
import { mockDeep, mockReset } from 'jest-mock-extended'; import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service'; import { InfraConfigService } from './infra-config.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { import {
InfraConfigEnum, INFRA_CONFIG_NOT_FOUND,
InfraConfigEnumForClient, INFRA_CONFIG_OPERATION_NOT_ALLOWED,
} from 'src/types/InfraConfig'; INFRA_CONFIG_UPDATE_FAILED,
import { INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_UPDATE_FAILED } from 'src/errors'; } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as helper from './helper'; import * as helper from './helper';
import { InfraConfig as dbInfraConfig } from '@prisma/client';
import { InfraConfig } from './infra-config.model';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>(); const mockConfigService = mockDeep<ConfigService>();
@@ -19,12 +22,82 @@ const infraConfigService = new InfraConfigService(
mockConfigService, mockConfigService,
); );
const INITIALIZED_DATE_CONST = new Date();
const dbInfraConfigs: dbInfraConfig[] = [
{
id: '3',
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: 'abcdefghijkl',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
{
id: '4',
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: 'google',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
];
const infraConfigs: InfraConfig[] = [
{
name: dbInfraConfigs[0].name as InfraConfigEnum,
value: dbInfraConfigs[0].value,
},
{
name: dbInfraConfigs[1].name as InfraConfigEnum,
value: dbInfraConfigs[1].value,
},
];
beforeEach(() => { beforeEach(() => {
mockReset(mockPrisma); mockReset(mockPrisma);
}); });
describe('InfraConfigService', () => { describe('InfraConfigService', () => {
describe('update', () => { describe('update', () => {
it('should update the infra config without backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(helper.stopApp).not.toHaveBeenCalled();
expect(result).toEqualRight({ name, value });
});
it('should update the infra config with backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value, true);
expect(helper.stopApp).toHaveBeenCalledTimes(1);
expect(result).toEqualRight({ name, value });
});
it('should update the infra config', async () => { it('should update the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID; const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true'; const value = 'true';
@@ -71,7 +144,7 @@ describe('InfraConfigService', () => {
describe('get', () => { describe('get', () => {
it('should get the infra config', async () => { it('should get the infra config', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID; const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true'; const value = 'true';
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({ mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
@@ -87,7 +160,7 @@ describe('InfraConfigService', () => {
}); });
it('should pass correct params to prisma findUnique', async () => { it('should pass correct params to prisma findUnique', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID; const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
await infraConfigService.get(name); await infraConfigService.get(name);
@@ -98,7 +171,7 @@ describe('InfraConfigService', () => {
}); });
it('should throw an error if the infra config does not exist', async () => { it('should throw an error if the infra config does not exist', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID; const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null'); mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
@@ -106,4 +179,45 @@ describe('InfraConfigService', () => {
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
}); });
}); });
describe('getMany', () => {
it('should throw error if any disallowed names are provided', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const result = await infraConfigService.getMany(disallowedNames);
expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
});
it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
disallowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(disallowedNames, false);
expect(result).toEqualRight(
infraConfigs.filter((i) => disallowedNames.includes(i.name)),
);
});
it('should return right with infraConfigs if Prisma query succeeds', async () => {
const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
allowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(allowedNames);
expect(result).toEqualRight(
infraConfigs.filter((i) => allowedNames.includes(i.name)),
);
});
});
}); });

View File

@@ -3,28 +3,25 @@ import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client'; import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { import { InfraConfigEnum } from 'src/types/InfraConfig';
InfraConfigEnum,
InfraConfigEnumForClient,
} from 'src/types/InfraConfig';
import { import {
AUTH_PROVIDER_NOT_SPECIFIED, AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST, DATABASE_TABLE_NOT_EXIST,
INFRA_CONFIG_INVALID_INPUT, INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_NOT_LISTED,
INFRA_CONFIG_RESET_FAILED, INFRA_CONFIG_RESET_FAILED,
INFRA_CONFIG_UPDATE_FAILED, INFRA_CONFIG_UPDATE_FAILED,
INFRA_CONFIG_SERVICE_NOT_CONFIGURED, INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
} from 'src/errors'; } from 'src/errors';
import { throwErr, validateSMTPEmail, validateSMTPUrl } from 'src/utils';
import { ConfigService } from '@nestjs/config';
import { import {
ServiceStatus, throwErr,
generateAnalyticsUserId, validateSMTPEmail,
getConfiguredSSOProviders, validateSMTPUrl,
stopApp, validateUrl,
} from './helper'; } from 'src/utils';
import { ConfigService } from '@nestjs/config';
import { ServiceStatus, getDefaultInfraConfigs, stopApp } from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args'; import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
@@ -35,84 +32,32 @@ export class InfraConfigService implements OnModuleInit {
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
EXCLUDE_FROM_UPDATE_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
async onModuleInit() { async onModuleInit() {
await this.initializeInfraConfigTable(); await this.initializeInfraConfigTable();
} }
async getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(),
},
{
name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generateAnalyticsUserId(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await this.prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
}
/** /**
* Initialize the 'infra_config' table with values from .env * Initialize the 'infra_config' table with values from .env
* @description This function create rows 'infra_config' in very first time (only once) * @description This function create rows 'infra_config' in very first time (only once)
*/ */
async initializeInfraConfigTable() { async initializeInfraConfigTable() {
try { try {
// Get all the 'names' of the properties to be saved in the 'infra_config' table
const enumValues = Object.values(InfraConfigEnum);
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table // Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs(); const infraConfigDefaultObjs = await getDefaultInfraConfigs();
// Check if all the 'names' are listed in the default values
if (enumValues.length !== infraConfigDefaultObjs.length) {
throw new Error(INFRA_CONFIG_NOT_LISTED);
}
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table // Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
const dbInfraConfigs = await this.prisma.infraConfig.findMany(); const dbInfraConfigs = await this.prisma.infraConfig.findMany();
@@ -169,11 +114,7 @@ export class InfraConfigService implements OnModuleInit {
* @param restartEnabled If true, restart the app after updating the InfraConfig * @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns InfraConfig model * @returns InfraConfig model
*/ */
async update( async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
name: InfraConfigEnumForClient | InfraConfigEnum,
value: string,
restartEnabled = false,
) {
const isValidate = this.validateEnvValues([{ name, value }]); const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left); if (E.isLeft(isValidate)) return E.left(isValidate.left);
@@ -197,6 +138,11 @@ export class InfraConfigService implements OnModuleInit {
* @returns InfraConfig model * @returns InfraConfig model
*/ */
async updateMany(infraConfigs: InfraConfigArgs[]) { async updateMany(infraConfigs: InfraConfigArgs[]) {
for (let i = 0; i < infraConfigs.length; i++) {
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
const isValidate = this.validateEnvValues(infraConfigs); const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left); if (E.isLeft(isValidate)) return E.left(isValidate.left);
@@ -230,12 +176,26 @@ export class InfraConfigService implements OnModuleInit {
) { ) {
switch (service) { switch (service) {
case AuthProvider.GOOGLE: case AuthProvider.GOOGLE:
return configMap.GOOGLE_CLIENT_ID && configMap.GOOGLE_CLIENT_SECRET; return (
configMap.GOOGLE_CLIENT_ID &&
configMap.GOOGLE_CLIENT_SECRET &&
configMap.GOOGLE_CALLBACK_URL &&
configMap.GOOGLE_SCOPE
);
case AuthProvider.GITHUB: case AuthProvider.GITHUB:
return configMap.GITHUB_CLIENT_ID && configMap.GITHUB_CLIENT_SECRET; return (
configMap.GITHUB_CLIENT_ID &&
configMap.GITHUB_CLIENT_SECRET &&
configMap.GITHUB_CALLBACK_URL &&
configMap.GITHUB_SCOPE
);
case AuthProvider.MICROSOFT: case AuthProvider.MICROSOFT:
return ( return (
configMap.MICROSOFT_CLIENT_ID && configMap.MICROSOFT_CLIENT_SECRET configMap.MICROSOFT_CLIENT_ID &&
configMap.MICROSOFT_CLIENT_SECRET &&
configMap.MICROSOFT_CALLBACK_URL &&
configMap.MICROSOFT_SCOPE &&
configMap.MICROSOFT_TENANT
); );
case AuthProvider.EMAIL: case AuthProvider.EMAIL:
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM; return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
@@ -310,7 +270,7 @@ export class InfraConfigService implements OnModuleInit {
* @param name Name of the InfraConfig * @param name Name of the InfraConfig
* @returns InfraConfig model * @returns InfraConfig model
*/ */
async get(name: InfraConfigEnumForClient) { async get(name: InfraConfigEnum) {
try { try {
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({ const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
where: { name }, where: { name },
@@ -327,7 +287,15 @@ export class InfraConfigService implements OnModuleInit {
* @param names Names of the InfraConfigs * @param names Names of the InfraConfigs
* @returns InfraConfig model * @returns InfraConfig model
*/ */
async getMany(names: InfraConfigEnumForClient[]) { async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) {
if (checkDisallowedKeys) {
// Check if the names are allowed to fetch by client
for (let i = 0; i < names.length; i++) {
if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i]))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
}
try { try {
const infraConfigs = await this.prisma.infraConfig.findMany({ const infraConfigs = await this.prisma.infraConfig.findMany({
where: { name: { in: names } }, where: { name: { in: names } },
@@ -354,7 +322,7 @@ export class InfraConfigService implements OnModuleInit {
*/ */
async reset() { async reset() {
try { try {
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs(); const infraConfigDefaultObjs = await getDefaultInfraConfigs();
await this.prisma.infraConfig.deleteMany({ await this.prisma.infraConfig.deleteMany({
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } }, where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
@@ -387,36 +355,60 @@ export class InfraConfigService implements OnModuleInit {
*/ */
validateEnvValues( validateEnvValues(
infraConfigs: { infraConfigs: {
name: InfraConfigEnumForClient | InfraConfigEnum; name: InfraConfigEnum;
value: string; value: string;
}[], }[],
) { ) {
for (let i = 0; i < infraConfigs.length; i++) { for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) { switch (infraConfigs[i].name) {
case InfraConfigEnumForClient.MAILER_SMTP_URL: case InfraConfigEnum.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value); const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.MAILER_ADDRESS_FROM: case InfraConfigEnum.MAILER_ADDRESS_FROM:
const isValidEmail = validateSMTPEmail(infraConfigs[i].value); const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.GOOGLE_CLIENT_ID: case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.GOOGLE_CLIENT_SECRET: case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.GITHUB_CLIENT_ID: case InfraConfigEnum.GOOGLE_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.GITHUB_CLIENT_SECRET: case InfraConfigEnum.GITHUB_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.MICROSOFT_CLIENT_ID: case InfraConfigEnum.GITHUB_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnumForClient.MICROSOFT_CLIENT_SECRET: case InfraConfigEnum.GITHUB_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_TENANT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
default: default:

View File

@@ -1,14 +1,14 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper'; import { ServiceStatus } from './helper';
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
@InputType() @InputType()
export class InfraConfigArgs { export class InfraConfigArgs {
@Field(() => InfraConfigEnumForClient, { @Field(() => InfraConfigEnum, {
description: 'Infra Config Name', description: 'Infra Config Name',
}) })
name: InfraConfigEnumForClient; name: InfraConfigEnum;
@Field({ @Field({
description: 'Infra Config Value', description: 'Infra Config Value',

View File

@@ -0,0 +1,14 @@
// Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = {
id: string;
title: string;
type: 'collection' | 'request';
method?: string;
};
// Type of data returned from the query to obtain all parents
export type ParentTreeQueryReturnType = {
id: string;
parentID: string;
title: string;
};

View File

@@ -0,0 +1,38 @@
import { Controller, Get, 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';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamMemberRole } from '@prisma/client';
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'team-collection', version: '1' })
export class TeamCollectionController {
constructor(private readonly teamCollectionService: TeamCollectionService) {}
@Get('search/:teamID/:searchQuery')
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
TeamMemberRole.OWNER,
)
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
async searchByTitle(
@Param('searchQuery') searchQuery: string,
@Param('teamID') teamID: string,
@Query('take') take: string,
@Query('skip') skip: string,
) {
const res = await this.teamCollectionService.searchByTitle(
searchQuery,
teamID,
parseInt(take),
parseInt(skip),
);
if (E.isLeft(res)) throwHTTPErr(res.left);
return res.right;
}
}

View File

@@ -6,6 +6,7 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe
import { TeamModule } from '../team/team.module'; import { TeamModule } from '../team/team.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { PubSubModule } from '../pubsub/pubsub.module'; import { PubSubModule } from '../pubsub/pubsub.module';
import { TeamCollectionController } from './team-collection.controller';
@Module({ @Module({
imports: [PrismaModule, TeamModule, UserModule, PubSubModule], imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
@@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module';
GqlCollectionTeamMemberGuard, GqlCollectionTeamMemberGuard,
], ],
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard], exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
controllers: [TeamCollectionController],
}) })
export class TeamCollectionModule {} export class TeamCollectionModule {}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TeamCollection } from './team-collection.model'; import { TeamCollection } from './team-collection.model';
import { import {
@@ -14,6 +14,10 @@ import {
TEAM_COL_SAME_NEXT_COLL, TEAM_COL_SAME_NEXT_COLL,
TEAM_COL_REORDERING_FAILED, TEAM_COL_REORDERING_FAILED,
TEAM_COLL_DATA_INVALID, TEAM_COLL_DATA_INVALID,
TEAM_REQ_SEARCH_FAILED,
TEAM_COL_SEARCH_FAILED,
TEAM_REQ_PARENT_TREE_GEN_FAILED,
TEAM_COLL_PARENT_TREE_GEN_FAILED,
} from '../errors'; } from '../errors';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { isValidLength } from 'src/utils'; import { isValidLength } from 'src/utils';
@@ -22,6 +26,9 @@ import * as O from 'fp-ts/Option';
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client'; import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
import { CollectionFolder } from 'src/types/CollectionFolder'; import { CollectionFolder } from 'src/types/CollectionFolder';
import { stringToJson } from 'src/utils'; import { stringToJson } from 'src/utils';
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
import { RESTError } from 'src/types/RESTError';
@Injectable() @Injectable()
export class TeamCollectionService { export class TeamCollectionService {
@@ -1056,4 +1063,266 @@ export class TeamCollectionService {
return E.left(TEAM_COLL_NOT_FOUND); return E.left(TEAM_COLL_NOT_FOUND);
} }
} }
/**
* Search for TeamCollections and TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
async searchByTitle(
searchQuery: string,
teamID: string,
take = 10,
skip = 0,
) {
// Fetch all collections and requests that match the search query
const searchResults: SearchQueryReturnType[] = [];
const matchedCollections = await this.searchCollections(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedCollections))
return E.left(<RESTError>{
message: matchedCollections.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedCollections.right);
const matchedRequests = await this.searchRequests(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedRequests))
return E.left(<RESTError>{
message: matchedRequests.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedRequests.right);
// Generate the parent tree for searchResults
const searchResultsWithTree: CollectionSearchNode[] = [];
for (let i = 0; i < searchResults.length; i++) {
const fetchedParentTree = await this.fetchParentTree(searchResults[i]);
if (E.isLeft(fetchedParentTree))
return E.left(<RESTError>{
message: fetchedParentTree.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResultsWithTree.push({
type: searchResults[i].type,
title: searchResults[i].title,
method: searchResults[i].method,
id: searchResults[i].id,
path: !fetchedParentTree
? []
: ([fetchedParentTree.right] as CollectionSearchNode[]),
});
}
return E.right({ data: searchResultsWithTree });
}
/**
* Search for TeamCollections by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchCollections(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
select id,title,'collection' AS type
from "TeamCollection"
where "TeamCollection"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_COL_SEARCH_FAILED);
}
}
/**
* Search for TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchRequests(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
select id,title,request->>'method' as method,'request' AS type
from "TeamRequest"
where "TeamRequest"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_REQ_SEARCH_FAILED);
}
}
/**
* Generate the parent tree of a search result
*
* @param searchResult The search result for which we want to generate the parent tree
* @returns The parent tree of the search result
*/
private async fetchParentTree(searchResult: SearchQueryReturnType) {
return searchResult.type === 'collection'
? await this.fetchCollectionParentTree(searchResult.id)
: await this.fetchRequestParentTree(searchResult.id);
}
/**
* Generate the parent tree of a collection
*
* @param id The ID of the collection
* @returns The parent tree of the collection
*/
private async fetchCollectionParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamCollection" AS tr ON tc.id = tr."parentID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const collectionParentTree = this.generateParentTree(res);
return E.right(collectionParentTree);
} catch (error) {
E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED);
}
}
/**
* Generate the parent tree from the collections
*
* @param parentCollections The parent collections
* @returns The parent tree of the parent collections
*/
private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) {
function findChildren(id) {
const collection = parentCollections.filter((item) => item.id === id)[0];
if (collection.parentID == null) {
return {
id: collection.id,
title: collection.title,
type: 'collection',
path: [],
};
}
const res = {
id: collection.id,
title: collection.title,
type: 'collection',
path: findChildren(collection.parentID),
};
return res;
}
if (parentCollections.length > 0) {
if (parentCollections[0].parentID == null) {
return {
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: [],
};
}
return {
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: findChildren(parentCollections[0].parentID),
};
}
return null;
}
/**
* Generate the parent tree of a request
*
* @param id The ID of the request
* @returns The parent tree of the request
*/
private async fetchRequestParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE request_collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN request_collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM request_collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const requestParentTree = this.generateParentTree(res);
return E.right(requestParentTree);
} catch (error) {
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
}
}
} }

View File

@@ -0,0 +1,47 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TeamService } from '../../team/team.service';
import { TeamMemberRole } from '../../team/team.model';
import {
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_NO_TEAM_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class RESTTeamMemberGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles)
throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 });
const request = context.switchToHttp().getRequest();
const { user } = request;
if (user == undefined)
throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 });
const teamID = request.params.teamID;
if (!teamID)
throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 });
const teamMember = await this.teamService.getTeamMember(teamID, user.uid);
if (!teamMember)
throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 });
if (requireRoles.includes(teamMember.role)) return true;
throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 });
}
}

View File

@@ -0,0 +1,17 @@
// Response type of results from the search query
export type CollectionSearchNode = {
/** Encodes the hierarchy of where the node is **/
path: CollectionSearchNode[];
} & (
| {
type: 'request';
title: string;
method: string;
id: string;
}
| {
type: 'collection';
title: string;
id: string;
}
);

View File

@@ -4,12 +4,19 @@ export enum InfraConfigEnum {
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
GOOGLE_SCOPE = 'GOOGLE_SCOPE',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID', GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET', GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL',
GITHUB_SCOPE = 'GITHUB_SCOPE',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID', MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET', MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
MICROSOFT_CALLBACK_URL = 'MICROSOFT_CALLBACK_URL',
MICROSOFT_SCOPE = 'MICROSOFT_SCOPE',
MICROSOFT_TENANT = 'MICROSOFT_TENANT',
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS', VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
@@ -17,20 +24,3 @@ export enum InfraConfigEnum {
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID', ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP', IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
} }
export enum InfraConfigEnumForClient {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
}

View File

@@ -1,10 +1,10 @@
import { HttpStatus } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
/** /**
** Custom interface to handle errors specific to Auth module ** Custom interface to handle errors for REST modules such as Auth, Admin modules
** Since its REST we need to return the HTTP status code along with the error message ** Since its REST we need to return the HTTP status code along with the error message
*/ */
export type AuthError = { export type RESTError = {
message: string; message: string;
statusCode: HttpStatus; statusCode: HttpStatus;
}; };

View File

@@ -1,4 +1,4 @@
import { ExecutionContext } from '@nestjs/common'; import { ExecutionContext, HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { pipe } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/function';
@@ -16,6 +16,7 @@ import {
JSON_INVALID, JSON_INVALID,
} from './errors'; } from './errors';
import { AuthProvider } from './auth/helper'; import { AuthProvider } from './auth/helper';
import { RESTError } from './types/RESTError';
/** /**
* A workaround to throw an exception in an expression. * A workaround to throw an exception in an expression.
@@ -27,6 +28,15 @@ export function throwErr(errMessage: string): never {
throw new Error(errMessage); throw new Error(errMessage);
} }
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: RESTError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/** /**
* Prints the given value to log and returns the same value. * Prints the given value to log and returns the same value.
* Used for debugging functional pipelines. * Used for debugging functional pipelines.
@@ -173,6 +183,16 @@ export const validateSMTPUrl = (url: string) => {
return false; return false;
}; };
/**
* Checks to see if the URL is valid or not
* @param url The URL to validate
* @returns boolean
*/
export const validateUrl = (url: string) => {
const urlRegex = /^(http|https):\/\/[^ "]+$/;
return urlRegex.test(url);
};
/** /**
* String to JSON parser * String to JSON parser
* @param {str} str The string to parse * @param {str} str The string to parse

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hoppscotch/cli", "name": "@hoppscotch/cli",
"version": "0.6.0", "version": "0.7.0",
"description": "A CLI to run Hoppscotch test scripts in CI environments.", "description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io", "homepage": "https://hoppscotch.io",
"type": "module", "type": "module",
@@ -41,30 +41,28 @@
"license": "MIT", "license": "MIT",
"private": false, "private": false,
"dependencies": { "dependencies": {
"axios": "^1.6.6", "axios": "1.6.7",
"chalk": "^5.3.0", "chalk": "5.3.0",
"commander": "^11.1.0", "commander": "11.1.0",
"lodash-es": "^4.17.21", "lodash-es": "4.17.21",
"qs": "^6.11.2", "qs": "6.11.2",
"zod": "^3.22.4" "verzod": "0.2.2",
"zod": "3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@relmify/jest-fp-ts": "^2.1.1", "@relmify/jest-fp-ts": "2.1.1",
"@swc/core": "^1.3.105", "@swc/core": "1.4.2",
"@types/jest": "^29.5.11", "@types/jest": "29.5.12",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "4.17.12",
"@types/qs": "^6.9.11", "@types/qs": "6.9.12",
"fp-ts": "^2.16.2", "fp-ts": "2.16.2",
"jest": "^29.7.0", "jest": "29.7.0",
"lodash": "^4.17.21", "prettier": "3.2.5",
"prettier": "^3.2.4", "qs": "6.11.2",
"qs": "^6.11.2", "ts-jest": "29.1.2",
"ts-jest": "^29.1.2", "tsup": "8.0.2",
"tsup": "^8.0.1", "typescript": "5.3.3"
"typescript": "^5.3.3",
"verzod": "^0.2.2",
"zod": "^3.22.4"
} }
} }

View File

@@ -17,7 +17,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestD", "name": "RequestD",
"params": [], "params": [],
@@ -40,7 +40,8 @@
"body": { "body": {
"contentType": null, "contentType": null,
"body": null "body": null
} },
"requestVariables": []
} }
], ],
"auth": { "auth": {
@@ -52,7 +53,7 @@
], ],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestC", "name": "RequestC",
"params": [], "params": [],
@@ -67,7 +68,8 @@
"body": { "body": {
"contentType": null, "contentType": null,
"body": null "body": null
} },
"requestVariables": []
} }
], ],
"auth": { "auth": {
@@ -88,7 +90,7 @@
], ],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestB", "name": "RequestB",
"params": [], "params": [],
@@ -104,6 +106,7 @@
"contentType": null, "contentType": null,
"body": null "body": null
}, },
"requestVariables": [],
"id": "clpttpdq00003qp16kut6doqv" "id": "clpttpdq00003qp16kut6doqv"
} }
], ],
@@ -116,7 +119,7 @@
], ],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestA", "name": "RequestA",
"params": [], "params": [],
@@ -132,6 +135,7 @@
"contentType": null, "contentType": null,
"body": null "body": null
}, },
"requestVariables": [],
"id": "clpttpdq00003qp16kut6doqv" "id": "clpttpdq00003qp16kut6doqv"
} }
], ],
@@ -158,7 +162,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestB", "name": "RequestB",
"params": [], "params": [],
@@ -174,6 +178,7 @@
"contentType": null, "contentType": null,
"body": null "body": null
}, },
"requestVariables": [],
"id": "clpttpdq00003qp16kut6doqv" "id": "clpttpdq00003qp16kut6doqv"
} }
], ],
@@ -186,7 +191,7 @@
], ],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"name": "RequestA", "name": "RequestA",
"params": [], "params": [],
@@ -202,6 +207,7 @@
"contentType": null, "contentType": null,
"body": null "body": null
}, },
"requestVariables": [],
"id": "clpttpdq00003qp16kut6doqv" "id": "clpttpdq00003qp16kut6doqv"
} }
], ],
@@ -218,4 +224,4 @@
"token": "BearerToken" "token": "BearerToken"
} }
} }
] ]

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "<<URL>>", "endpoint": "<<URL>>",
"name": "test1", "name": "test1",
"params": [], "params": [],
@@ -16,7 +16,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}" "body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"
} },
"requestVariables": []
} }
] ]
} }

View File

@@ -5,7 +5,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "", "name": "",
"params": [], "params": [],
@@ -23,10 +23,11 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
} },
"requestVariables": [],
}, },
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.dio/<<HEADERS_TYPE2>>", "endpoint": "https://echo.hoppscotch.dio/<<HEADERS_TYPE2>>",
"name": "success", "name": "success",
"params": [], "params": [],
@@ -44,7 +45,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
} },
"requestVariables": []
} }
] ]
} }

View File

@@ -2,9 +2,9 @@
{ {
"v": 1, "v": 1,
"folders": [], "folders": [],
"requests": "requests":
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "fail", "name": "fail",
"params": [], "params": [],
@@ -22,10 +22,11 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
} },
"requestVariables": [],
}, },
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
"name": "success", "name": "success",
"params": [], "params": [],
@@ -43,7 +44,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
} },
"requestVariables": []
} }
] ]
} }

View File

@@ -2,9 +2,9 @@
{ {
"v": 1, "v": 1,
"folders": [], "folders": [],
"requests": "requests":
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "fail", "name": "fail",
"params": [], "params": [],
@@ -22,7 +22,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
} },
"requestVariables": []
} }
] ]
} }

View File

@@ -5,7 +5,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
"name": "", "name": "",
"params": [], "params": [],
@@ -23,10 +23,11 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
} },
"requestVariables": []
}, },
{ {
"v": "1", "v": "2",
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>", "endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
"name": "success", "name": "success",
"params": [], "params": [],
@@ -44,7 +45,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}" "body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
} },
"requestVariables": []
} }
] ]
} }

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null }, "body": { "body": null, "contentType": null },
"name": "sample-req", "name": "sample-req",
@@ -13,7 +13,8 @@
"headers": [], "headers": [],
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")", "testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")",
"preRequestScript": "pw.env.set(\"variable\", \"value\");" "preRequestScript": "pw.env.set(\"variable\", \"value\");",
"requestVariables": []
} }
], ],
"auth": { "authType": "inherit", "authActive": true }, "auth": { "authType": "inherit", "authActive": true },

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"name": "test-request", "name": "test-request",
"endpoint": "https://echo.hoppscotch.io", "endpoint": "https://echo.hoppscotch.io",
"method": "POST", "method": "POST",
@@ -19,7 +19,8 @@
"body": "{\n \"firstName\": \"<<firstName>>\",\n \"lastName\": \"<<lastName>>\",\n \"greetText\": \"<<salutation>>, <<fullName>>\",\n \"fullName\": \"<<fullName>>\",\n \"id\": \"<<id>>\"\n}" "body": "{\n \"firstName\": \"<<firstName>>\",\n \"lastName\": \"<<lastName>>\",\n \"greetText\": \"<<salutation>>, <<fullName>>\",\n \"fullName\": \"<<fullName>>\",\n \"id\": \"<<id>>\"\n}"
}, },
"preRequestScript": "", "preRequestScript": "",
"testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});" "testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});",
"requestVariables": []
} }
], ],
"auth": { "auth": {

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null }, "body": { "body": null, "contentType": null },
"name": "test-secret-headers", "name": "test-secret-headers",
@@ -17,12 +17,13 @@
"active": true "active": true
} }
], ],
"requestVariables": [],
"endpoint": "<<baseURL>>/headers", "endpoint": "<<baseURL>>/headers",
"testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.get(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.get(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})", "testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.get(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.get(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})",
"preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" "preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"body": { "body": {
"body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}", "body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}",
@@ -32,12 +33,13 @@
"method": "POST", "method": "POST",
"params": [], "params": [],
"headers": [], "headers": [],
"requestVariables": [],
"endpoint": "<<baseURL>>/post", "endpoint": "<<baseURL>>/post",
"testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})",
"preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" "preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null }, "body": { "body": null, "contentType": null },
"name": "test-secret-query-params", "name": "test-secret-query-params",
@@ -50,12 +52,13 @@
} }
], ],
"headers": [], "headers": [],
"requestVariables": [],
"endpoint": "<<baseURL>>/get", "endpoint": "<<baseURL>>/get",
"testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})",
"preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" "preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "basic", "authType": "basic",
"password": "<<secretBasicAuthPassword>>", "password": "<<secretBasicAuthPassword>>",
@@ -67,12 +70,13 @@
"method": "GET", "method": "GET",
"params": [], "params": [],
"headers": [], "headers": [],
"requestVariables": [],
"endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>", "endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>",
"testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});",
"preRequestScript": "" "preRequestScript": ""
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"token": "<<secretBearerToken>>", "token": "<<secretBearerToken>>",
"authType": "bearer", "authType": "bearer",
@@ -85,18 +89,20 @@
"method": "GET", "method": "GET",
"params": [], "params": [],
"headers": [], "headers": [],
"requestVariables": [],
"endpoint": "<<baseURL>>/bearer", "endpoint": "<<baseURL>>/bearer",
"testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.get(\"secretBearerToken\")\n const preReqSecretBearerToken = pw.env.get(\"preReqSecretBearerToken\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.get(\"secretBearerToken\")\n const preReqSecretBearerToken = pw.env.get(\"preReqSecretBearerToken\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});",
"preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)" "preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null }, "body": { "body": null, "contentType": null },
"name": "test-secret-fallback", "name": "test-secret-fallback",
"method": "GET", "method": "GET",
"params": [], "params": [],
"headers": [], "headers": [],
"requestVariables": [],
"endpoint": "<<baseURL>>", "endpoint": "<<baseURL>>",
"testScript": "pw.test(\"Returns an empty string if the value for a secret environment variable is not found in the system environment\", () => {\n pw.expect(pw.env.get(\"nonExistentValueInSystemEnv\")).toBe(\"\")\n})", "testScript": "pw.test(\"Returns an empty string if the value for a secret environment variable is not found in the system environment\", () => {\n pw.expect(pw.env.get(\"nonExistentValueInSystemEnv\")).toBe(\"\")\n})",
"preRequestScript": "" "preRequestScript": ""

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "none", "authType": "none",
"authActive": true "authActive": true
@@ -16,6 +16,7 @@
"name": "test-secret-headers", "name": "test-secret-headers",
"method": "GET", "method": "GET",
"params": [], "params": [],
"requestVariables": [],
"headers": [ "headers": [
{ {
"key": "Secret-Header-Key", "key": "Secret-Header-Key",
@@ -28,7 +29,7 @@
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "none", "authType": "none",
"authActive": true "authActive": true
@@ -40,6 +41,7 @@
"name": "test-secret-headers-overrides", "name": "test-secret-headers-overrides",
"method": "GET", "method": "GET",
"params": [], "params": [],
"requestVariables": [],
"headers": [ "headers": [
{ {
"key": "Secret-Header-Key", "key": "Secret-Header-Key",
@@ -52,7 +54,7 @@
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)" "preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "none", "authType": "none",
"authActive": true "authActive": true
@@ -64,13 +66,14 @@
"name": "test-secret-body", "name": "test-secret-body",
"method": "POST", "method": "POST",
"params": [], "params": [],
"requestVariables": [],
"headers": [], "headers": [],
"endpoint": "<<baseURL>>/post", "endpoint": "<<baseURL>>/post",
"testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})", "testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})",
"preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)" "preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "none", "authType": "none",
"authActive": true "authActive": true
@@ -88,13 +91,14 @@
"active": true "active": true
} }
], ],
"requestVariables": [],
"headers": [], "headers": [],
"endpoint": "<<baseURL>>/get", "endpoint": "<<baseURL>>/get",
"testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})", "testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})",
"preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)" "preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"authType": "basic", "authType": "basic",
"password": "<<secretBasicAuthPassword>>", "password": "<<secretBasicAuthPassword>>",
@@ -108,13 +112,14 @@
"name": "test-secret-basic-auth", "name": "test-secret-basic-auth",
"method": "GET", "method": "GET",
"params": [], "params": [],
"requestVariables": [],
"headers": [], "headers": [],
"endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>", "endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>",
"testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});", "testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});",
"preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}" "preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}"
}, },
{ {
"v": "1", "v": "2",
"auth": { "auth": {
"token": "<<secretBearerToken>>", "token": "<<secretBearerToken>>",
"authType": "bearer", "authType": "bearer",
@@ -129,6 +134,7 @@
"name": "test-secret-bearer-auth", "name": "test-secret-bearer-auth",
"method": "GET", "method": "GET",
"params": [], "params": [],
"requestVariables": [],
"headers": [], "headers": [],
"endpoint": "<<baseURL>>/bearer", "endpoint": "<<baseURL>>/bearer",
"testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.resolve(\"<<secretBearerToken>>\")\n const preReqSecretBearerToken = pw.env.resolve(\"<<preReqSecretBearerToken>>\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});", "testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.resolve(\"<<secretBearerToken>>\")\n const preReqSecretBearerToken = pw.env.resolve(\"<<preReqSecretBearerToken>>\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});",

View File

@@ -4,7 +4,7 @@
"folders": [], "folders": [],
"requests": [ "requests": [
{ {
"v": "1", "v": "2",
"endpoint": "https://httpbin.org/post", "endpoint": "https://httpbin.org/post",
"name": "req", "name": "req",
"params": [], "params": [],
@@ -22,7 +22,8 @@
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n \"key\": \"<<customBodyValue>>\"\n}" "body": "{\n \"key\": \"<<customBodyValue>>\"\n}"
} },
"requestVariables": []
} }
], ],
"auth": { "authType": "inherit", "authActive": false }, "auth": { "authType": "inherit", "authActive": false },

View File

@@ -563,12 +563,22 @@ details[open] summary .indicator {
.env-highlight { .env-highlight {
@apply text-accentContrast; @apply text-accentContrast;
&.env-found { &.request-variable-highlight {
@apply bg-accentDark; @apply bg-amber-500;
@apply hover:bg-accent; @apply hover:bg-amber-600;
} }
&.env-not-found { &.environment-variable-highlight {
@apply bg-green-500;
@apply hover:bg-green-600;
}
&.global-variable-highlight {
@apply bg-blue-500;
@apply hover:bg-blue-600;
}
&.environment-not-found-highlight {
@apply bg-red-500; @apply bg-red-500;
@apply hover:bg-red-600; @apply hover:bg-red-600;
} }

View File

@@ -154,7 +154,7 @@
"invalid_name": "Please provide a name for the collection", "invalid_name": "Please provide a name for the collection",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "Collection already in the root",
"moved": "Moved Successfully", "moved": "Moved Successfully",
"my_collections": "My Collections", "my_collections": "Personal Collections",
"name": "My New Collection", "name": "My New Collection",
"name_length_insufficient": "Collection name should be at least 3 characters long", "name_length_insufficient": "Collection name should be at least 3 characters long",
"new": "New Collection", "new": "New Collection",
@@ -166,14 +166,12 @@
"save_as": "Save as", "save_as": "Save as",
"save_to_collection": "Save to Collection", "save_to_collection": "Save to Collection",
"select": "Select a Collection", "select": "Select a Collection",
"select_location": "Select location", "select_location": "Select location"
"select_team": "Select a team",
"team_collections": "Team Collections"
}, },
"confirm": { "confirm": {
"close_unsaved_tab": "Are you sure you want to close this tab?", "close_unsaved_tab": "Are you sure you want to close this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.", "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"exit_team": "Are you sure you want to leave this team?", "exit_team": "Are you sure you want to leave this workspace?",
"logout": "Are you sure you want to logout?", "logout": "Are you sure you want to logout?",
"remove_collection": "Are you sure you want to permanently delete this collection?", "remove_collection": "Are you sure you want to permanently delete this collection?",
"remove_environment": "Are you sure you want to permanently delete this environment?", "remove_environment": "Are you sure you want to permanently delete this environment?",
@@ -181,7 +179,7 @@
"remove_history": "Are you sure you want to permanently delete all history?", "remove_history": "Are you sure you want to permanently delete all history?",
"remove_request": "Are you sure you want to permanently delete this request?", "remove_request": "Are you sure you want to permanently delete this request?",
"remove_shared_request": "Are you sure you want to permanently delete this shared request?", "remove_shared_request": "Are you sure you want to permanently delete this shared request?",
"remove_team": "Are you sure you want to delete this team?", "remove_team": "Are you sure you want to delete this workspace?",
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?", "remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Do you want to save changes made in this tab?",
@@ -234,18 +232,19 @@
"headers": "This request does not have any headers", "headers": "This request does not have any headers",
"history": "History is empty", "history": "History is empty",
"invites": "Invite list is empty", "invites": "Invite list is empty",
"members": "Team is empty", "members": "Workspace is empty",
"parameters": "This request does not have any parameters", "parameters": "This request does not have any parameters",
"pending_invites": "There are no pending invites for this team", "pending_invites": "There are no pending invites for this workspace",
"profile": "Login to view your profile", "profile": "Login to view your profile",
"protocols": "Protocols are empty", "protocols": "Protocols are empty",
"request_variables": "This request does not have any request variables",
"schema": "Connect to a GraphQL endpoint to view schema", "schema": "Connect to a GraphQL endpoint to view schema",
"secret_environments": "Secrets are not synced to Hoppscotch", "secret_environments": "Secrets are not synced to Hoppscotch",
"shared_requests": "Shared requests are empty", "shared_requests": "Shared requests are empty",
"shared_requests_logout": "Login to view your shared requests or create a new one", "shared_requests_logout": "Login to view your shared requests or create a new one",
"subscription": "Subscriptions are empty", "subscription": "Subscriptions are empty",
"team_name": "Team name empty", "team_name": "Workspace name empty",
"teams": "You don't belong to any teams", "teams": "You don't belong to any workspaces",
"tests": "There are no tests for this request" "tests": "There are no tests for this request"
}, },
"environment": { "environment": {
@@ -262,7 +261,7 @@
"import_or_create": "Import or create a environment", "import_or_create": "Import or create a environment",
"invalid_name": "Please provide a name for the environment", "invalid_name": "Please provide a name for the environment",
"list": "Environment variables", "list": "Environment variables",
"my_environments": "My Environments", "my_environments": "Personal Environments",
"name": "Name", "name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels", "nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment", "new": "New Environment",
@@ -277,7 +276,7 @@
"select": "Select environment", "select": "Select environment",
"set": "Set environment", "set": "Set environment",
"set_as_environment": "Set as environment", "set_as_environment": "Set as environment",
"team_environments": "Team Environments", "team_environments": "Workspace Environments",
"title": "Environments", "title": "Environments",
"updated": "Environment updated", "updated": "Environment updated",
"value": "Value", "value": "Value",
@@ -292,8 +291,8 @@
"check_how_to_add_origin": "Check how you can add an origin", "check_how_to_add_origin": "Check how you can add an origin",
"curl_invalid_format": "cURL is not formatted properly", "curl_invalid_format": "cURL is not formatted properly",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:", "delete_account": "Your account is currently an owner in these workspaces:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", "delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.",
"empty_req_name": "Empty Request Name", "empty_req_name": "Empty Request Name",
"f12_details": "(F12 for details)", "f12_details": "(F12 for details)",
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again", "gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
@@ -315,7 +314,8 @@
"proxy_error": "Proxy error", "proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script", "script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script" "test_script_fail": "Could not execute post-request script",
"reading_files": "Error while reading one or more files."
}, },
"export": { "export": {
"as_json": "Export as JSON", "as_json": "Export as JSON",
@@ -394,8 +394,8 @@
"from_insomnia_description": "Import from Insomnia collection", "from_insomnia_description": "Import from Insomnia collection",
"from_json": "Import from Hoppscotch", "from_json": "Import from Hoppscotch",
"from_json_description": "Import from Hoppscotch collection file", "from_json_description": "Import from Hoppscotch collection file",
"from_my_collections": "Import from My Collections", "from_my_collections": "Import from Personal Collections",
"from_my_collections_description": "Import from My Collections file", "from_my_collections_description": "Import from Personal Collections file",
"from_openapi": "Import from OpenAPI", "from_openapi": "Import from OpenAPI",
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman", "from_postman": "Import from Postman",
@@ -413,7 +413,10 @@
"json_description": "Import collections from a Hoppscotch Collections JSON file", "json_description": "Import collections from a Hoppscotch Collections JSON file",
"postman_environment": "Postman Environment", "postman_environment": "Postman Environment",
"postman_environment_description": "Import Postman Environment from a JSON file", "postman_environment_description": "Import Postman Environment from a JSON file",
"title": "Import" "title": "Import",
"file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported",
"file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.",
"success": "Successfully imported"
}, },
"inspections": { "inspections": {
"description": "Inspect possible errors", "description": "Inspect possible errors",
@@ -509,7 +512,7 @@
"email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.", "email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.",
"no_permission": "You do not have permission to perform this action.", "no_permission": "You do not have permission to perform this action.",
"owner": "Owner", "owner": "Owner",
"owner_description": "Owners can add, edit, and delete requests, collections and team members.", "owner_description": "Owners can add, edit, and delete requests, collections and workspace members.",
"roles": "Roles", "roles": "Roles",
"roles_description": "Roles are used to control access to the shared collections.", "roles_description": "Roles are used to control access to the shared collections.",
"updated": "Profile updated", "updated": "Profile updated",
@@ -556,6 +559,7 @@
"raw_body": "Raw Request Body", "raw_body": "Raw Request Body",
"rename": "Rename Request", "rename": "Rename Request",
"renamed": "Request renamed", "renamed": "Request renamed",
"request_variables": "Request variables",
"run": "Run", "run": "Run",
"save": "Save", "save": "Save",
"save_as": "Save as", "save_as": "Save as",
@@ -813,12 +817,12 @@
"title": "Tabs" "title": "Tabs"
}, },
"workspace": { "workspace": {
"delete": "Delete current team", "delete": "Delete current workspace",
"edit": "Edit current team", "edit": "Edit current workspace",
"invite": "Invite people to team", "invite": "Invite people to workspace",
"new": "Create new team", "new": "Create new workspace",
"switch_to_personal": "Switch to your personal workspace", "switch_to_personal": "Switch to your personal workspace",
"title": "Teams" "title": "Workspaces"
} }
}, },
"sse": { "sse": {
@@ -875,7 +879,6 @@
"forum": "Ask questions and get answers", "forum": "Ask questions and get answers",
"github": "Follow us on Github", "github": "Follow us on Github",
"shortcuts": "Browse app faster", "shortcuts": "Browse app faster",
"team": "Get in touch with the team",
"title": "Support", "title": "Support",
"twitter": "Follow us on Twitter" "twitter": "Follow us on Twitter"
}, },
@@ -906,60 +909,59 @@
"websocket": "WebSocket" "websocket": "WebSocket"
}, },
"team": { "team": {
"already_member": "You are already a member of this team. Contact your team owner.", "already_member": "You are already a member of this workspace. Contact your workspace owner.",
"create_new": "Create new team", "create_new": "Create new workspace",
"deleted": "Team deleted", "deleted": "Workspace deleted",
"edit": "Edit Team", "edit": "Edit Workspace",
"email": "E-mail", "email": "E-mail",
"email_do_not_match": "Email doesn't match with your account details. Contact your team owner.", "email_do_not_match": "Email doesn't match with your account details. Contact your workspace owner.",
"exit": "Exit Team", "exit": "Exit Workspace",
"exit_disabled": "Only owner cannot exit the team", "exit_disabled": "Only owner cannot exit the workspace",
"failed_invites": "Failed invites", "failed_invites": "Failed invites",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "Invalid collection ID",
"invalid_email_format": "Email format is invalid", "invalid_email_format": "Email format is invalid",
"invalid_id": "Invalid team ID. Contact your team owner.", "invalid_id": "Invalid workspace ID. Contact your workspace owner.",
"invalid_invite_link": "Invalid invite link", "invalid_invite_link": "Invalid invite link",
"invalid_invite_link_description": "The link you followed is invalid. Contact your team owner.", "invalid_invite_link_description": "The link you followed is invalid. Contact your workspace owner.",
"invalid_member_permission": "Please provide a valid permission to the team member", "invalid_member_permission": "Please provide a valid permission to the workspace member",
"invite": "Invite", "invite": "Invite",
"invite_more": "Invite more", "invite_more": "Invite more",
"invite_tooltip": "Invite people to this workspace", "invite_tooltip": "Invite people to this workspace",
"invited_to_team": "{owner} invited you to join {team}", "invited_to_team": "{owner} invited you to join {workspace}",
"join": "Invitation accepted", "join": "Invitation accepted",
"join_beta": "Join the beta program to access teams.", "join_team": "Join {workspace}",
"join_team": "Join {team}", "joined_team": "You have joined {workspace}",
"joined_team": "You have joined {team}", "joined_team_description": "You are now a member of this workspace",
"joined_team_description": "You are now a member of this team", "left": "You left the workspace",
"left": "You left the team",
"login_to_continue": "Login to continue", "login_to_continue": "Login to continue",
"login_to_continue_description": "You need to be logged in to join a team.", "login_to_continue_description": "You need to be logged in to join a workspace.",
"logout_and_try_again": "Logout and sign in with another account", "logout_and_try_again": "Logout and sign in with another account",
"member_has_invite": "This email ID already has an invite. Contact your team owner.", "member_has_invite": "This email ID already has an invite. Contact your workspace owner.",
"member_not_found": "Member not found. Contact your team owner.", "member_not_found": "Member not found. Contact your workspace owner.",
"member_removed": "User removed", "member_removed": "User removed",
"member_role_updated": "User roles updated", "member_role_updated": "User roles updated",
"members": "Members", "members": "Members",
"more_members": "+{count} more", "more_members": "+{count} more",
"name_length_insufficient": "Team name should be at least 6 characters long", "name_length_insufficient": "Workspace name should be at least 6 characters long",
"name_updated": "Team name updated", "name_updated": "Workspace name updated",
"new": "New Team", "new": "New Workspace",
"new_created": "New team created", "new_created": "New workspace created",
"new_name": "My New Team", "new_name": "My New Workspace",
"no_access": "You do not have edit access to this team", "no_access": "You do not have edit access to this workspace",
"no_invite_found": "Invitation not found. Contact your team owner.", "no_invite_found": "Invitation not found. Contact your workspace owner.",
"no_request_found": "Request not found.", "no_request_found": "Request not found.",
"not_found": "Team not found. Contact your team owner.", "not_found": "Workspace not found. Contact your workspace owner.",
"not_valid_viewer": "You are not a valid viewer. Contact your team owner.", "not_valid_viewer": "You are not a valid viewer. Contact your workspace owner.",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "Cannot move collection to a child collection",
"pending_invites": "Pending invites", "pending_invites": "Pending invites",
"permissions": "Permissions", "permissions": "Permissions",
"same_target_destination": "Same target and destination", "same_target_destination": "Same target and destination",
"saved": "Team saved", "saved": "Workspace saved",
"select_a_team": "Select a team", "select_a_team": "Select a workspace",
"success_invites": "Success invites", "success_invites": "Success invites",
"title": "Teams", "title": "Workspaces",
"we_sent_invite_link": "We sent an invite link to all invitees!", "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 team." "we_sent_invite_link_description": "Ask all invitees to check their inbox. Click on the link to join the workspace."
}, },
"team_environment": { "team_environment": {
"deleted": "Environment Deleted", "deleted": "Environment Deleted",
@@ -985,8 +987,15 @@
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "Change workspace",
"personal": "My Workspace", "personal": "Personal Workspace",
"team": "Team Workspace", "personal_workspace": "{name}'s Workspace",
"other_workspaces": "My Workspaces",
"team": "Workspace",
"title": "Workspaces" "title": "Workspaces"
},
"site_protection": {
"login_to_continue": "Login to continue",
"login_to_continue_description": "You need to be logged in to access this Hoppscotch Enterprise Instance.",
"error_fetching_site_protection_status": "Something Went Wrong While Fetching Site Protection Status"
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@hoppscotch/common", "name": "@hoppscotch/common",
"private": true, "private": true,
"version": "2023.12.6", "version": "2024.3.0",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run", "test": "vitest --run",
@@ -21,147 +21,147 @@
"do-lintfix": "pnpm run lintfix" "do-lintfix": "pnpm run lintfix"
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "10.1.0",
"@codemirror/autocomplete": "^6.11.1", "@codemirror/autocomplete": "6.13.0",
"@codemirror/commands": "^6.3.2", "@codemirror/commands": "6.3.3",
"@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-javascript": "6.2.2",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "6.0.1",
"@codemirror/lang-xml": "^6.0.2", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.9.3", "@codemirror/language": "6.10.1",
"@codemirror/legacy-modes": "^6.3.3", "@codemirror/legacy-modes": "6.3.3",
"@codemirror/lint": "^6.4.2", "@codemirror/lint": "6.5.0",
"@codemirror/search": "^6.5.5", "@codemirror/search": "6.5.6",
"@codemirror/state": "^6.3.3", "@codemirror/state": "6.4.1",
"@codemirror/view": "^6.22.3", "@codemirror/view": "6.25.1",
"@hoppscotch/codemirror-lang-graphql": "workspace:^", "@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "^0.1.0", "@hoppscotch/ui": "0.1.0",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0", "@lezer/highlight": "1.2.0",
"@unhead/vue": "^1.8.8", "@unhead/vue": "1.8.8",
"@urql/core": "^4.2.0", "@urql/core": "4.2.0",
"@urql/devtools": "^2.0.3", "@urql/devtools": "2.0.3",
"@urql/exchange-auth": "^2.1.6", "@urql/exchange-auth": "2.1.6",
"@urql/exchange-graphcache": "^6.3.3", "@urql/exchange-graphcache": "6.4.0",
"@vitejs/plugin-legacy": "^4.1.1", "@vitejs/plugin-legacy": "4.1.1",
"@vueuse/core": "^10.6.1", "@vueuse/core": "10.7.0",
"acorn-walk": "^8.3.0", "acorn-walk": "8.3.0",
"axios": "^1.6.2", "axios": "1.6.2",
"buffer": "^6.0.3", "buffer": "6.0.3",
"cookie-es": "^1.0.0", "cookie-es": "1.0.0",
"dioc": "^1.0.1", "dioc": "1.0.1",
"esprima": "^4.0.1", "esprima": "4.0.1",
"events": "^3.3.0", "events": "3.3.0",
"fp-ts": "^2.16.1", "fp-ts": "2.16.1",
"globalthis": "^1.0.3", "globalthis": "1.0.3",
"graphql": "^16.8.1", "graphql": "16.8.1",
"graphql-language-service-interface": "^2.10.2", "graphql-language-service-interface": "2.10.2",
"graphql-tag": "^2.12.6", "graphql-tag": "2.12.6",
"httpsnippet": "^3.0.1", "httpsnippet": "3.0.1",
"insomnia-importers": "^3.6.0", "insomnia-importers": "3.6.0",
"io-ts": "^2.2.20", "io-ts": "2.2.20",
"js-yaml": "^4.1.0", "js-yaml": "4.1.0",
"jsonpath-plus": "^7.2.0", "jsonpath-plus": "7.2.0",
"lodash-es": "^4.17.21", "lodash-es": "4.17.21",
"lossless-json": "^3.0.2", "lossless-json": "3.0.2",
"minisearch": "^6.3.0", "minisearch": "6.3.0",
"nprogress": "^0.2.0", "nprogress": "0.2.0",
"paho-mqtt": "^1.1.0", "paho-mqtt": "1.1.0",
"path": "^0.12.7", "path": "0.12.7",
"postman-collection": "^4.3.0", "postman-collection": "4.3.0",
"process": "^0.11.10", "process": "0.11.10",
"qs": "^6.11.2", "qs": "6.11.2",
"quicktype-core": "^23.0.79", "quicktype-core": "23.0.79",
"rxjs": "^7.8.1", "rxjs": "7.8.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "2.6.0",
"set-cookie-parser-es": "^1.0.5", "set-cookie-parser-es": "1.0.5",
"socket.io-client-v2": "npm:socket.io-client@^2.4.0", "socket.io-client-v2": "npm:socket.io-client@2.4.0",
"socket.io-client-v3": "npm:socket.io-client@^3.1.3", "socket.io-client-v3": "npm:socket.io-client@3.1.3",
"socket.io-client-v4": "npm:socket.io-client@^4.4.1", "socket.io-client-v4": "npm:socket.io-client@4.4.1",
"socketio-wildcard": "^2.0.0", "socketio-wildcard": "2.0.0",
"splitpanes": "^3.1.5", "splitpanes": "3.1.5",
"stream-browserify": "^3.0.0", "stream-browserify": "3.0.0",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "0.11.0",
"tern": "^0.24.3", "tern": "0.24.3",
"timers": "^0.1.1", "timers": "0.1.1",
"tippy.js": "^6.3.7", "tippy.js": "6.3.7",
"url": "^0.11.3", "url": "0.11.3",
"util": "^0.12.5", "util": "0.12.5",
"uuid": "^9.0.1", "uuid": "9.0.1",
"verzod": "^0.2.0", "verzod": "0.2.2",
"vue": "^3.3.8", "vue": "3.3.9",
"vue-i18n": "^9.7.1", "vue-i18n": "9.8.0",
"vue-pdf-embed": "^1.2.1", "vue-pdf-embed": "1.2.1",
"vue-router": "^4.2.5", "vue-router": "4.2.5",
"vue-tippy": "6.3.1", "vue-tippy": "6.3.1",
"vuedraggable-es": "^4.1.1", "vuedraggable-es": "4.1.1",
"wonka": "^6.3.4", "wonka": "6.3.4",
"workbox-window": "^7.0.0", "workbox-window": "7.0.0",
"xml-formatter": "^3.6.0", "xml-formatter": "3.6.0",
"yargs-parser": "^21.1.1", "yargs-parser": "21.1.1",
"zod": "^3.22.4" "zod": "3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@esbuild-plugins/node-modules-polyfill": "0.2.2",
"@graphql-codegen/add": "^5.0.0", "@graphql-codegen/add": "5.0.0",
"@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/typed-document-node": "^5.0.1", "@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql-graphcache": "^3.0.0", "@graphql-codegen/typescript-urql-graphcache": "3.0.0",
"@graphql-codegen/urql-introspection": "^3.0.0", "@graphql-codegen/urql-introspection": "3.0.0",
"@graphql-typed-document-node/core": "^3.2.0", "@graphql-typed-document-node/core": "3.2.0",
"@iconify-json/lucide": "^1.1.141", "@iconify-json/lucide": "1.1.144",
"@intlify/vite-plugin-vue-i18n": "^7.0.0", "@intlify/vite-plugin-vue-i18n": "7.0.0",
"@relmify/jest-fp-ts": "^2.1.1", "@relmify/jest-fp-ts": "2.1.1",
"@rushstack/eslint-patch": "^1.6.0", "@rushstack/eslint-patch": "1.6.0",
"@types/har-format": "^1.2.15", "@types/har-format": "1.2.15",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "4.17.12",
"@types/lossless-json": "^1.0.4", "@types/lossless-json": "1.0.4",
"@types/nprogress": "^0.2.3", "@types/nprogress": "0.2.3",
"@types/paho-mqtt": "^1.0.10", "@types/paho-mqtt": "1.0.10",
"@types/postman-collection": "^3.5.10", "@types/postman-collection": "3.5.10",
"@types/splitpanes": "^2.2.6", "@types/splitpanes": "2.2.6",
"@types/uuid": "^9.0.7", "@types/uuid": "9.0.7",
"@types/yargs-parser": "^21.0.3", "@types/yargs-parser": "21.0.3",
"@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "^6.12.0", "@typescript-eslint/parser": "6.13.2",
"@vitejs/plugin-vue": "^4.5.0", "@vitejs/plugin-vue": "4.5.1",
"@vue/compiler-sfc": "^3.3.8", "@vue/compiler-sfc": "3.3.10",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "12.0.0",
"@vue/runtime-core": "^3.3.8", "@vue/runtime-core": "3.3.10",
"autoprefixer": "^10.4.14", "autoprefixer": "10.4.16",
"cross-env": "^7.0.3", "cross-env": "7.0.3",
"dotenv": "^16.3.1", "dotenv": "16.3.1",
"eslint": "^8.54.0", "eslint": "8.55.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "5.0.1",
"eslint-plugin-vue": "^9.18.1", "eslint-plugin-vue": "9.19.2",
"glob": "^10.3.10", "glob": "10.3.10",
"npm-run-all": "^4.1.5", "npm-run-all": "4.1.5",
"openapi-types": "^12.1.3", "openapi-types": "12.1.3",
"postcss": "^8.4.23", "postcss": "8.4.31",
"prettier": "^3.1.0", "prettier": "3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7", "prettier-plugin-tailwindcss": "0.5.7",
"rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-polyfill-node": "0.13.0",
"sass": "^1.69.5", "sass": "1.69.5",
"tailwindcss": "^3.3.2", "tailwindcss": "3.3.5",
"typescript": "^5.3.2", "typescript": "5.3.2",
"unplugin-fonts": "^1.1.1", "unplugin-fonts": "1.1.1",
"unplugin-icons": "^0.17.4", "unplugin-icons": "0.17.4",
"unplugin-vue-components": "^0.25.2", "unplugin-vue-components": "0.25.2",
"vite": "^4.5.0", "vite": "4.5.0",
"vite-plugin-checker": "^0.6.2", "vite-plugin-checker": "0.6.2",
"vite-plugin-fonts": "^0.7.0", "vite-plugin-fonts": "0.7.0",
"vite-plugin-html-config": "^1.0.11", "vite-plugin-html-config": "1.0.11",
"vite-plugin-inspect": "^0.7.42", "vite-plugin-inspect": "0.7.42",
"vite-plugin-pages": "^0.31.0", "vite-plugin-pages": "0.31.0",
"vite-plugin-pages-sitemap": "^1.6.1", "vite-plugin-pages-sitemap": "1.6.1",
"vite-plugin-pwa": "^0.17.0", "vite-plugin-pwa": "0.17.3",
"vite-plugin-vue-layouts": "^0.8.0", "vite-plugin-vue-layouts": "0.8.0",
"vitest": "^0.34.6", "vitest": "0.34.6",
"vue-tsc": "^1.8.22" "vue-tsc": "1.8.24"
} }
} }

View File

@@ -1,11 +1,11 @@
// generated by unplugin-vue-components /* eslint-disable */
// We suggest you to commit this file into source control /* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {} export {}
declare module '@vue/runtime-core' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppBanner: typeof import('./components/app/Banner.vue')['default'] AppBanner: typeof import('./components/app/Banner.vue')['default']
@@ -145,6 +145,7 @@ declare module '@vue/runtime-core' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -154,6 +155,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideX: typeof import('~icons/lucide/x')['default'] IconLucideX: typeof import('~icons/lucide/x')['default']
@@ -209,5 +211,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default'] WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default'] WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
} }
} }

View File

@@ -1,8 +1,8 @@
<template> <template>
<div <div
ref="contextMenuRef" ref="contextMenuRef"
class="fixed translate-y-8 transform rounded border border-dividerDark bg-popover p-2 shadow-lg" class="fixed transform -translate-x-10 -translate-y-8 rounded border border-dividerDark bg-popover p-2 shadow-lg"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`" :style="`top: ${position.top}px; left: ${position.left}px; z-index: 100;`"
> >
<div v-if="contextMenuOptions" class="flex flex-col"> <div v-if="contextMenuOptions" class="flex flex-col">
<div <div

View File

@@ -330,11 +330,16 @@ const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const workspace = workspaceService.currentWorkspace const workspace = workspaceService.currentWorkspace
const workspaceName = computed(() => const workspaceName = computed(() => {
workspace.value.type === "personal" if (workspace.value.type === "personal") {
? t("workspace.personal") return currentUser.value?.displayName
: workspace.value.teamName ? t("workspace.personal_workspace", {
) name: currentUser.value.displayName,
})
: t("workspace.personal")
}
return workspace.value.teamName
})
const refetchTeams = () => { const refetchTeams = () => {
teamListAdapter.fetchList() teamListAdapter.fetchList()

View File

@@ -263,7 +263,7 @@ const HoppOpenAPIImporter: ImporterOrExporter = {
step: UrlSource({ step: UrlSource({
caption: "import.from_url", caption: "import.from_url",
onImportFromURL: async (content) => { onImportFromURL: async (content) => {
const res = await hoppOpenAPIImporter(content)() const res = await hoppOpenAPIImporter([content])()
if (E.isRight(res)) { if (E.isRight(res)) {
handleImportToStore(res.right) handleImportToStore(res.right)

View File

@@ -694,7 +694,7 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
let target = collections[indexPaths.shift() as number] let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0) while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number] target = target?.folders[indexPaths.shift() as number]
return target !== undefined ? target : null return target !== undefined ? target : null
} }

View File

@@ -32,9 +32,13 @@
{{ tab.document.request.method }} {{ tab.document.request.method }}
</span> </span>
<div <div
class="flex items-center flex-1 flex-shrink-0 min-w-0 px-4 py-2 truncate rounded-r" class="flex items-center flex-1 flex-shrink-0 min-w-0 truncate rounded-r"
> >
{{ tab.document.request.endpoint }} <SmartEnvInput
v-model="tab.document.request.endpoint"
:readonly="true"
:envs="tabRequestVariables"
/>
</div> </div>
</div> </div>
<div class="flex mt-2 space-x-2 sm:mt-0"> <div class="flex mt-2 space-x-2 sm:mt-0">
@@ -69,6 +73,7 @@
v-model="tab.document.request" v-model="tab.document.request"
v-model:option-tab="selectedOptionTab" v-model:option-tab="selectedOptionTab"
:properties="properties" :properties="properties"
:envs="tabRequestVariables"
/> />
<HttpResponse <HttpResponse
v-if="tab.document.response" v-if="tab.document.response"
@@ -117,6 +122,15 @@ const sharedRequestURL = computed(() => {
return `${shortcodeBaseURL}/r/${props.sharedRequestID}` return `${shortcodeBaseURL}/r/${props.sharedRequestID}`
}) })
const tabRequestVariables = computed(() => {
return tab.value.document.request.requestVariables.map(({ key, value }) => ({
key,
value,
secret: false,
sourceEnv: "RequestVariable",
}))
})
const { subscribeToStream } = useStreamSubscriber() const { subscribeToStream } = useStreamSubscriber()
const newSendRequest = async () => { const newSendRequest = async () => {

View File

@@ -133,7 +133,7 @@ const PostmanEnvironmentsImport: ImporterOrExporter = {
return return
} }
handleImportToStore([res.right]) handleImportToStore(res.right)
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT", type: "HOPP_IMPORT_ENVIRONMENT",
@@ -166,19 +166,14 @@ const insomniaEnvironmentsImport: ImporterOrExporter = {
return return
} }
const globalEnvIndex = res.right.findIndex( const globalEnvs = res.right.filter(
(env) => env.name === "Base Environment" (env) => env.name === "Base Environment"
) )
const otherEnvs = res.right.filter(
(env) => env.name !== "Base Environment"
)
const globalEnv = handleImportToStore(otherEnvs, globalEnvs)
globalEnvIndex !== -1 ? res.right[globalEnvIndex] : undefined
// remove the global env from the environments array to prevent it from being imported twice
if (globalEnvIndex !== -1) {
res.right.splice(globalEnvIndex, 1)
}
handleImportToStore(res.right, globalEnv)
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT", type: "HOPP_IMPORT_ENVIRONMENT",
@@ -340,14 +335,14 @@ const showImportFailedError = () => {
const handleImportToStore = async ( const handleImportToStore = async (
environments: Environment[], environments: Environment[],
globalEnv?: NonSecretEnvironment globalEnvs: NonSecretEnvironment[] = []
) => { ) => {
// if there's a global env, add them to the store // Add global envs to the store
if (globalEnv) { globalEnvs.forEach(({ variables }) => {
globalEnv.variables.forEach(({ key, value, secret }) => variables.forEach(({ key, value, secret }) => {
addGlobalEnvVariable({ key, value, secret }) addGlobalEnvVariable({ key, value, secret })
) })
} })
if (props.environmentType === "MY_ENV") { if (props.environmentType === "MY_ENV") {
appendEnvironments(environments) appendEnvironments(environments)

View File

@@ -150,7 +150,7 @@
<div v-else class="flex flex-1 border-b border-dividerLight"> <div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight"> <div class="w-2/3 border-r border-dividerLight">
<div v-if="auth.authType === 'basic'"> <div v-if="auth.authType === 'basic'">
<HttpAuthorizationBasic v-model="auth" /> <HttpAuthorizationBasic v-model="auth" :envs="envs" />
</div> </div>
<div v-if="auth.authType === 'inherit'" class="p-4"> <div v-if="auth.authType === 'inherit'" class="p-4">
<span v-if="inheritedProperties?.auth"> <span v-if="inheritedProperties?.auth">
@@ -169,17 +169,26 @@
</div> </div>
<div v-if="auth.authType === 'bearer'"> <div v-if="auth.authType === 'bearer'">
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="auth.token" placeholder="Token" /> <SmartEnvInput
v-model="auth.token"
placeholder="Token"
:auto-complete-env="true"
:envs="envs"
/>
</div> </div>
</div> </div>
<div v-if="auth.authType === 'oauth-2'"> <div v-if="auth.authType === 'oauth-2'">
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="auth.token" placeholder="Token" /> <SmartEnvInput
v-model="auth.token"
placeholder="Token"
:envs="envs"
/>
</div> </div>
<HttpOAuth2Authorization v-model="auth" /> <HttpOAuth2Authorization v-model="auth" :envs="envs" />
</div> </div>
<div v-if="auth.authType === 'api-key'"> <div v-if="auth.authType === 'api-key'">
<HttpAuthorizationApiKey v-model="auth" /> <HttpAuthorizationApiKey v-model="auth" :envs="envs" />
</div> </div>
</div> </div>
<div <div
@@ -215,6 +224,7 @@ import { useColorMode } from "@composables/theming"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { onMounted } from "vue" import { onMounted } from "vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments"
const t = useI18n() const t = useI18n()
@@ -225,6 +235,7 @@ const props = defineProps<{
isCollectionProperty?: boolean isCollectionProperty?: boolean
isRootCollection?: boolean isRootCollection?: boolean
inheritedProperties?: HoppInheritedProperty inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -100,10 +100,12 @@
<HttpBodyParameters <HttpBodyParameters
v-if="body.contentType === 'multipart/form-data'" v-if="body.contentType === 'multipart/form-data'"
v-model="body" v-model="body"
:envs="envs"
/> />
<HttpURLEncodedParams <HttpURLEncodedParams
v-else-if="body.contentType === 'application/x-www-form-urlencoded'" v-else-if="body.contentType === 'application/x-www-form-urlencoded'"
v-model="body" v-model="body"
:envs="envs"
/> />
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" /> <HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
<HoppSmartPlaceholder <HoppSmartPlaceholder
@@ -141,6 +143,7 @@ import IconExternalLink from "~icons/lucide/external-link"
import IconInfo from "~icons/lucide/info" import IconInfo from "~icons/lucide/info"
import IconRefreshCW from "~icons/lucide/refresh-cw" import IconRefreshCW from "~icons/lucide/refresh-cw"
import { RESTOptionTabs } from "./RequestOptions.vue" import { RESTOptionTabs } from "./RequestOptions.vue"
import { AggregateEnvironment } from "~/newstore/environments"
const colorMode = useColorMode() const colorMode = useColorMode()
const t = useI18n() const t = useI18n()
@@ -148,6 +151,7 @@ const t = useI18n()
const props = defineProps<{ const props = defineProps<{
body: HoppRESTReqBody body: HoppRESTReqBody
headers: HoppRESTHeader[] headers: HoppRESTHeader[]
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -65,6 +65,8 @@
<SmartEnvInput <SmartEnvInput
v-model="entry.key" v-model="entry.key"
:placeholder="`${t('count.parameter', { count: index + 1 })}`" :placeholder="`${t('count.parameter', { count: index + 1 })}`"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateBodyParam(index, { updateBodyParam(index, {
key: $event, key: $event,
@@ -87,6 +89,8 @@
<SmartEnvInput <SmartEnvInput
v-model="entry.value" v-model="entry.value"
:placeholder="`${t('count.value', { count: index + 1 })}`" :placeholder="`${t('count.value', { count: index + 1 })}`"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateBodyParam(index, { updateBodyParam(index, {
key: entry.key, key: entry.key,
@@ -190,11 +194,13 @@ import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { AggregateEnvironment } from "~/newstore/environments"
type Body = HoppRESTReqBody & { contentType: "multipart/form-data" } type Body = HoppRESTReqBody & { contentType: "multipart/form-data" }
const props = defineProps<{ const props = defineProps<{
modelValue: Body modelValue: Body
envs: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -188,11 +188,25 @@ const copyCodeIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
const requestCode = computed(() => { const requestCode = computed(() => {
const aggregateEnvs = getAggregateEnvs() const aggregateEnvs = getAggregateEnvs()
const requestVariables = request.value.requestVariables.map(
(requestVariable) => {
if (requestVariable.active)
return {
key: requestVariable.key,
value: requestVariable.value,
secret: false,
}
return {}
}
)
const env: Environment = { const env: Environment = {
v: 1, v: 1,
id: "env", id: "env",
name: "Env", name: "Env",
variables: aggregateEnvs, variables: [
...(requestVariables as Environment["variables"]),
...aggregateEnvs,
],
} }
const effectiveRequest = getEffectiveRESTRequest(request.value, env) const effectiveRequest = getEffectiveRESTRequest(request.value, env)
@@ -212,6 +226,12 @@ const requestCode = computed(() => {
active: true, active: true,
})), })),
endpoint: effectiveRequest.effectiveFinalURL, endpoint: effectiveRequest.effectiveFinalURL,
requestVariables: effectiveRequest.effectiveFinalRequestVariables.map(
(requestVariable) => ({
...requestVariable,
active: true,
})
),
}) })
) )

View File

@@ -26,7 +26,7 @@
@click="clearContent()" @click="clearContent()"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-if="bulkMode" v-if="bulkHeaders"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')" :title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }" :class="{ '!text-accent': WRAP_LINES }"
@@ -92,6 +92,8 @@
:auto-complete-source="commonHeaders" :auto-complete-source="commonHeaders"
:env-index="index" :env-index="index"
:inspection-results="getInspectorResult(headerKeyResults, index)" :inspection-results="getInspectorResult(headerKeyResults, index)"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateHeader(index, { updateHeader(index, {
id: header.id, id: header.id,
@@ -108,6 +110,8 @@
getInspectorResult(headerValueResults, index) getInspectorResult(headerValueResults, index)
" "
:env-index="index" :env-index="index"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateHeader(index, { updateHeader(index, {
id: header.id, id: header.id,
@@ -329,7 +333,11 @@ import {
getComputedHeaders, getComputedHeaders,
getComputedAuthHeaders, getComputedAuthHeaders,
} from "~/helpers/utils/EffectiveURL" } from "~/helpers/utils/EffectiveURL"
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments" import {
AggregateEnvironment,
aggregateEnvs$,
getAggregateEnvs,
} from "~/newstore/environments"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { InspectionService, InspectorResult } from "~/services/inspection" import { InspectionService, InspectorResult } from "~/services/inspection"
@@ -359,6 +367,7 @@ const props = defineProps<{
modelValue: HoppRESTRequest modelValue: HoppRESTRequest
isCollectionProperty?: boolean isCollectionProperty?: boolean
inheritedProperties?: HoppInheritedProperty inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -6,31 +6,52 @@
:styles=" :styles="
hasAccessTokenOrAuthURL ? 'pointer-events-none opacity-70' : '' hasAccessTokenOrAuthURL ? 'pointer-events-none opacity-70' : ''
" "
:auto-complete-env="true"
placeholder="OpenID Connect Discovery URL" placeholder="OpenID Connect Discovery URL"
:envs="envs"
/> />
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput <SmartEnvInput
v-model="authURL" v-model="authURL"
placeholder="Authorization URL" placeholder="Authorization URL"
:auto-complete-env="true"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''" :styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
></SmartEnvInput> :envs="envs"
/>
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput <SmartEnvInput
v-model="accessTokenURL" v-model="accessTokenURL"
placeholder="Access Token URL" placeholder="Access Token URL"
:auto-complete-env="true"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''" :styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
:envs="envs"
/> />
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="clientID" placeholder="Client ID" /> <SmartEnvInput
v-model="clientID"
:auto-complete-env="true"
placeholder="Client ID"
:envs="envs"
/>
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="clientSecret" placeholder="Client Secret" /> <SmartEnvInput
v-model="clientSecret"
:auto-complete-env="true"
placeholder="Client Secret"
:envs="envs"
/>
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="scope" placeholder="Scope" /> <SmartEnvInput
v-model="scope"
:auto-complete-env="true"
placeholder="Scope"
:envs="envs"
/>
</div> </div>
<div class="p-2"> <div class="p-2">
<HoppButtonSecondary <HoppButtonSecondary
@@ -62,6 +83,7 @@ const toast = useToast()
const props = defineProps<{ const props = defineProps<{
modelValue: HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2 modelValue: HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -87,6 +87,8 @@
:inspection-results=" :inspection-results="
getInspectorResult(parameterKeyResults, index) getInspectorResult(parameterKeyResults, index)
" "
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateParam(index, { updateParam(index, {
id: param.id, id: param.id,
@@ -102,6 +104,8 @@
:inspection-results=" :inspection-results="
getInspectorResult(parameterValueResults, index) getInspectorResult(parameterValueResults, index)
" "
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateParam(index, { updateParam(index, {
id: param.id, id: param.id,
@@ -209,6 +213,7 @@ import { InspectionService, InspectorResult } from "~/services/inspection"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import { useNestedSetting } from "~/composables/settings" import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings" import { toggleNestedSetting } from "~/newstore/settings"
import { AggregateEnvironment } from "~/newstore/environments"
const colorMode = useColorMode() const colorMode = useColorMode()
@@ -242,6 +247,7 @@ useCodemirror(
const props = defineProps<{ const props = defineProps<{
modelValue: HoppRESTParam[] modelValue: HoppRESTParam[]
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -56,6 +56,7 @@
v-model="tab.document.request.endpoint" v-model="tab.document.request.endpoint"
:placeholder="`${t('request.url')}`" :placeholder="`${t('request.url')}`"
:auto-complete-source="userHistories" :auto-complete-source="userHistories"
:auto-complete-env="true"
:inspection-results="tabResults" :inspection-results="tabResults"
@paste="onPasteUrl($event)" @paste="onPasteUrl($event)"
@enter="newSendRequest" @enter="newSendRequest"

View File

@@ -5,48 +5,51 @@
render-inactive-tabs render-inactive-tabs
> >
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('params') : true" v-if="properties?.includes('params') ?? true"
:id="'params'" :id="'params'"
:label="`${t('tab.parameters')}`" :label="`${t('tab.parameters')}`"
:info="`${newActiveParamsCount$}`" :info="`${newActiveParamsCount}`"
> >
<HttpParameters v-model="request.params" /> <HttpParameters v-model="request.params" :envs="envs" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('bodyParams') : true" v-if="properties?.includes('bodyParams') ?? true"
:id="'bodyParams'" :id="'bodyParams'"
:label="`${t('tab.body')}`" :label="`${t('tab.body')}`"
> >
<HttpBody <HttpBody
v-model:headers="request.headers" v-model:headers="request.headers"
v-model:body="request.body" v-model:body="request.body"
:envs="envs"
@change-tab="changeOptionTab" @change-tab="changeOptionTab"
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('headers') : true" v-if="properties?.includes('headers') ?? true"
:id="'headers'" :id="'headers'"
:label="`${t('tab.headers')}`" :label="`${t('tab.headers')}`"
:info="`${newActiveHeadersCount$}`" :info="`${newActiveHeadersCount}`"
> >
<HttpHeaders <HttpHeaders
v-model="request" v-model="request"
:inherited-properties="inheritedProperties" :inherited-properties="inheritedProperties"
:envs="envs"
@change-tab="changeOptionTab" @change-tab="changeOptionTab"
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('authorization') : true" v-if="properties?.includes('authorization') ?? true"
:id="'authorization'" :id="'authorization'"
:label="`${t('tab.authorization')}`" :label="`${t('tab.authorization')}`"
> >
<HttpAuthorization <HttpAuthorization
v-model="request.auth" v-model="request.auth"
:inherited-properties="inheritedProperties" :inherited-properties="inheritedProperties"
:envs="envs"
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('preRequestScript') : true" v-if="properties?.includes('preRequestScript') ?? true"
:id="'preRequestScript'" :id="'preRequestScript'"
:label="`${t('tab.pre_request_script')}`" :label="`${t('tab.pre_request_script')}`"
:indicator=" :indicator="
@@ -58,7 +61,7 @@
<HttpPreRequestScript v-model="request.preRequestScript" /> <HttpPreRequestScript v-model="request.preRequestScript" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('tests') : true" v-if="properties?.includes('tests') ?? true"
:id="'tests'" :id="'tests'"
:label="`${t('tab.tests')}`" :label="`${t('tab.tests')}`"
:indicator=" :indicator="
@@ -67,6 +70,15 @@
> >
<HttpTests v-model="request.testScript" /> <HttpTests v-model="request.testScript" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab
v-if="properties?.includes('requestVariables') ?? true"
:id="'requestVariables'"
:label="`${t('tab.variables')}`"
:info="`${newActiveRequestVariablesCount}`"
:align-last="true"
>
<HttpRequestVariables v-model="request.requestVariables" />
</HoppSmartTab>
</HoppSmartTabs> </HoppSmartTabs>
</template> </template>
@@ -77,6 +89,7 @@ import { useVModel } from "@vueuse/core"
import { computed } from "vue" import { computed } from "vue"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments"
const VALID_OPTION_TABS = [ const VALID_OPTION_TABS = [
"params", "params",
@@ -85,6 +98,7 @@ const VALID_OPTION_TABS = [
"authorization", "authorization",
"preRequestScript", "preRequestScript",
"tests", "tests",
"requestVariables",
] as const ] as const
export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number] export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number]
@@ -98,6 +112,7 @@ const props = withDefaults(
optionTab: RESTOptionTabs optionTab: RESTOptionTabs
properties?: string[] properties?: string[]
inheritedProperties?: HoppInheritedProperty inheritedProperties?: HoppInheritedProperty
envs?: AggregateEnvironment[]
}>(), }>(),
{ {
optionTab: "params", optionTab: "params",
@@ -116,22 +131,27 @@ const changeOptionTab = (e: RESTOptionTabs) => {
selectedOptionTab.value = e selectedOptionTab.value = e
} }
const newActiveParamsCount$ = computed(() => { const newActiveParamsCount = computed(() => {
const e = request.value.params.filter( const count = request.value.params.filter(
(x) => x.active && (x.key !== "" || x.value !== "") (x) => x.active && (x.key || x.value)
).length ).length
if (e === 0) return null return count ? count : null
return `${e}`
}) })
const newActiveHeadersCount$ = computed(() => { const newActiveHeadersCount = computed(() => {
const e = request.value.headers.filter( const count = request.value.headers.filter(
(x) => x.active && (x.key !== "" || x.value !== "") (x) => x.active && (x.key || x.value)
).length ).length
if (e === 0) return null return count ? count : null
return `${e}` })
const newActiveRequestVariablesCount = computed(() => {
const count = request.value.requestVariables.filter(
(x) => x.active && (x.key || x.value)
).length
return count ? count : null
}) })
defineActionHandler("request.open-tab", ({ tab }) => { defineActionHandler("request.open-tab", ({ tab }) => {

View File

@@ -0,0 +1,412 @@
<template>
<div class="flex flex-1 flex-col">
<div
class="sticky top-upperMobileSecondaryStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperSecondaryStickyFold"
>
<label class="truncate font-semibold text-secondaryLight">
{{ t("request.request_variables") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/rest-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent()"
/>
<HoppButtonSecondary
v-if="bulkVariables"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="
toggleNestedSetting('WRAP_LINES', 'httpRequestVariables')
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.bulk_mode')"
:icon="IconEdit"
:class="{ '!text-accent': bulkMode }"
@click="bulkMode = !bulkMode"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
:disabled="bulkMode"
@click="addVariable"
/>
</div>
</div>
<div v-if="bulkMode" class="h-full relative">
<div ref="bulkEditor" class="absolute inset-0"></div>
</div>
<div v-else>
<draggable
v-model="workingRequestVariables"
item-key="id"
animation="250"
handle=".draggable-handle"
draggable=".draggable-content"
ghost-class="cursor-move"
chosen-class="bg-primaryLight"
drag-class="cursor-grabbing"
>
<template #item="{ element: variable, index }">
<div
class="draggable-content group flex divide-x divide-dividerLight border-b border-dividerLight"
>
<HoppButtonSecondary
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
content:
index !== workingRequestVariables?.length - 1
? t('action.drag_to_reorder')
: null,
}"
:icon="IconGripVertical"
class="opacity-0"
:class="{
'draggable-handle cursor-grab group-hover:opacity-100':
index !== workingRequestVariables?.length - 1,
}"
tabindex="-1"
/>
<SmartEnvInput
v-model="variable.key"
:placeholder="`${t('count.variable', { count: index + 1 })}`"
@change="
updateVariable(index, {
id: variable.id,
key: $event,
value: variable.value,
active: variable.active,
})
"
/>
<SmartEnvInput
v-model="variable.value"
:placeholder="`${t('count.value', { count: index + 1 })}`"
@change="
updateVariable(index, {
id: variable.id,
key: variable.key,
value: $event,
active: variable.active,
})
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
variable.hasOwnProperty('active')
? variable.active
? t('action.turn_off')
: t('action.turn_on')
: t('action.turn_off')
"
:icon="
variable.hasOwnProperty('active')
? variable.active
? IconCheckCircle
: IconCircle
: IconCheckCircle
"
color="green"
@click="
updateVariable(index, {
id: variable.id,
key: variable.key,
value: variable.value,
active: variable.hasOwnProperty('active')
? !variable.active
: false,
})
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteVariable(index)"
/>
</div>
</template>
</draggable>
<HoppSmartPlaceholder
v-if="workingRequestVariables.length === 0"
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.request_variables')}`"
:text="t('empty.request_variables')"
>
<template #body>
<HoppButtonSecondary
:label="`${t('add.new')}`"
:icon="IconPlus"
filled
@click="addVariable"
/>
</template>
</HoppSmartPlaceholder>
</div>
</div>
</template>
<script lang="ts" setup>
import { throwError } from "@functional/error"
import {
HoppRESTRequestVariable,
RawKeyValueEntry,
parseRawKeyValueEntriesE,
rawKeyValueEntriesToString,
} from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray"
import { flow, pipe } from "fp-ts/function"
import { cloneDeep, isEqual } from "lodash-es"
import { reactive, ref, watch } from "vue"
import draggable from "vuedraggable-es"
import { useCodemirror } from "~/composables/codemirror"
import { useI18n } from "~/composables/i18n"
import { useNestedSetting } from "~/composables/settings"
import { useColorMode } from "~/composables/theming"
import { useToast } from "~/composables/toast"
import linter from "~/helpers/editor/linting/rawKeyValue"
import { objRemoveKey } from "~/helpers/functional/object"
import { toggleNestedSetting } from "~/newstore/settings"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconEdit from "~icons/lucide/edit"
import IconGripVertical from "~icons/lucide/grip-vertical"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconPlus from "~icons/lucide/plus"
import IconTrash from "~icons/lucide/trash"
import IconTrash2 from "~icons/lucide/trash-2"
import IconWrapText from "~icons/lucide/wrap-text"
const colorMode = useColorMode()
const t = useI18n()
const toast = useToast()
const bulkMode = ref(false)
const bulkEditor = ref<any | null>(null)
const bulkVariables = ref("")
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpRequestVariables")
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const props = defineProps<{
modelValue: HoppRESTRequestVariable[]
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: Array<HoppRESTRequestVariable>): void
}>()
// The functional requestVariable list (the requestVariable actually applied to the session)
const requestVariables = useVModel(props, "modelValue", emit)
useCodemirror(
bulkEditor,
bulkVariables,
reactive({
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: WRAP_LINES,
},
linter,
completer: null,
environmentHighlights: true,
})
)
const idTicker = ref(0)
const workingRequestVariables = ref<
Array<HoppRESTRequestVariable & { id: number }>
>([
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
])
// Sync logic between params and working/bulk params
watch(
requestVariables,
(newRequestVariableList) => {
// Sync should overwrite working params
const filteredWorkingRequestVariables: HoppRESTRequestVariable[] = pipe(
workingRequestVariables.value,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
const filteredBulkRequestVariables = pipe(
parseRawKeyValueEntriesE(bulkVariables.value),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(newRequestVariableList, filteredWorkingRequestVariables)) {
workingRequestVariables.value = pipe(
newRequestVariableList,
A.map((x) => ({ id: idTicker.value++, ...x }))
)
}
if (!isEqual(newRequestVariableList, filteredBulkRequestVariables)) {
bulkVariables.value = rawKeyValueEntriesToString(newRequestVariableList)
}
},
{ immediate: true }
)
watch(workingRequestVariables, (newWorkingRequestVariables) => {
const fixedRequestVariables = pipe(
newWorkingRequestVariables,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
if (!isEqual(requestVariables.value, fixedRequestVariables)) {
requestVariables.value = cloneDeep(fixedRequestVariables)
}
})
watch(bulkVariables, (newBulkParams) => {
const filteredBulkRequestVariables = pipe(
parseRawKeyValueEntriesE(newBulkParams),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(requestVariables.value, filteredBulkRequestVariables)) {
requestVariables.value = filteredBulkRequestVariables
}
})
// Rule: Working Request variable always have last element is always an empty param
watch(workingRequestVariables, (variableList) => {
if (
variableList.length > 0 &&
variableList[variableList.length - 1].key !== ""
) {
workingRequestVariables.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
}
})
const addVariable = () => {
workingRequestVariables.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
}
const updateVariable = (index: number, variable: any & { id: number }) => {
workingRequestVariables.value = workingRequestVariables.value.map((h, i) =>
i === index ? variable : h
)
}
const deleteVariable = (index: number) => {
const requestVariablesBeforeDeletion = cloneDeep(
workingRequestVariables.value
)
if (
!(
requestVariablesBeforeDeletion.length > 0 &&
index === requestVariablesBeforeDeletion.length - 1
)
) {
if (deletionToast.value) {
deletionToast.value.goAway(0)
deletionToast.value = null
}
deletionToast.value = toast.success(`${t("state.deleted")}`, {
action: [
{
text: `${t("action.undo")}`,
onClick: (_, toastObject) => {
workingRequestVariables.value = requestVariablesBeforeDeletion
toastObject.goAway(0)
deletionToast.value = null
},
},
],
onComplete: () => {
deletionToast.value = null
},
})
}
workingRequestVariables.value = pipe(
workingRequestVariables.value,
A.deleteAt(index),
O.getOrElseW(() =>
throwError("Working Request Variable Deletion Out of Bounds")
)
)
}
const clearContent = () => {
// set params list to the initial state
workingRequestVariables.value = [
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
]
bulkVariables.value = ""
}
</script>

View File

@@ -21,7 +21,7 @@
@click="clearContent()" @click="clearContent()"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-if="bulkMode" v-if="bulkUrlEncodedParams"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')" :title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }" :class="{ '!text-accent': WRAP_LINES }"
@@ -84,6 +84,8 @@
<SmartEnvInput <SmartEnvInput
v-model="param.key" v-model="param.key"
:placeholder="`${t('count.parameter', { count: index + 1 })}`" :placeholder="`${t('count.parameter', { count: index + 1 })}`"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateUrlEncodedParam(index, { updateUrlEncodedParam(index, {
id: param.id, id: param.id,
@@ -96,6 +98,8 @@
<SmartEnvInput <SmartEnvInput
v-model="param.value" v-model="param.value"
:placeholder="`${t('count.value', { count: index + 1 })}`" :placeholder="`${t('count.value', { count: index + 1 })}`"
:auto-complete-env="true"
:envs="envs"
@change=" @change="
updateUrlEncodedParam(index, { updateUrlEncodedParam(index, {
id: param.id, id: param.id,
@@ -200,6 +204,7 @@ import { throwError } from "~/helpers/functional/error"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings" import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings" import { toggleNestedSetting } from "~/newstore/settings"
import { AggregateEnvironment } from "~/newstore/environments"
type Body = HoppRESTReqBody & { type Body = HoppRESTReqBody & {
contentType: "application/x-www-form-urlencoded" contentType: "application/x-www-form-urlencoded"
@@ -207,6 +212,7 @@ type Body = HoppRESTReqBody & {
const props = defineProps<{ const props = defineProps<{
modelValue: Body modelValue: Body
envs: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -1,9 +1,19 @@
<template> <template>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="auth.key" placeholder="Key" /> <SmartEnvInput
v-model="auth.key"
:auto-complete-env="true"
placeholder="Key"
:envs="envs"
/>
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="auth.value" placeholder="Value" /> <SmartEnvInput
v-model="auth.value"
:auto-complete-env="true"
placeholder="Value"
:envs="envs"
/>
</div> </div>
<div class="flex items-center border-b border-dividerLight"> <div class="flex items-center border-b border-dividerLight">
<span class="flex items-center"> <span class="flex items-center">
@@ -65,11 +75,13 @@ import { useI18n } from "@composables/i18n"
import { HoppRESTAuthAPIKey } from "@hoppscotch/data" import { HoppRESTAuthAPIKey } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { ref } from "vue" import { ref } from "vue"
import { AggregateEnvironment } from "~/newstore/environments"
const t = useI18n() const t = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: HoppRESTAuthAPIKey modelValue: HoppRESTAuthAPIKey
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -3,12 +3,16 @@
<SmartEnvInput <SmartEnvInput
v-model="auth.username" v-model="auth.username"
:placeholder="t('authorization.username')" :placeholder="t('authorization.username')"
:auto-complete-env="true"
:envs="envs"
/> />
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput <SmartEnvInput
v-model="auth.password" v-model="auth.password"
:placeholder="t('authorization.password')" :placeholder="t('authorization.password')"
:auto-complete-env="true"
:envs="envs"
/> />
</div> </div>
</template> </template>
@@ -17,11 +21,13 @@
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { HoppRESTAuthBasic } from "@hoppscotch/data" import { HoppRESTAuthBasic } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import { AggregateEnvironment } from "~/newstore/environments"
const t = useI18n() const t = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: HoppRESTAuthBasic modelValue: HoppRESTAuthBasic
envs?: AggregateEnvironment[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -13,6 +13,7 @@
{{ t(`${caption}`) }} {{ t(`${caption}`) }}
</span> </span>
</p> </p>
<div <div
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark" class="flex flex-col ml-10 border border-dashed rounded border-dividerDark"
> >
@@ -23,15 +24,30 @@
type="file" type="file"
class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark" class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
:accept="acceptedFileTypes" :accept="acceptedFileTypes"
multiple
@change="onFileChange" @change="onFileChange"
/> />
</div> </div>
<p v-if="showFileSizeLimitExceededWarning" class="text-red-500 ml-10">
<template v-if="importFilesCount">
{{
t("import.file_size_limit_exceeded_warning_multiple_files", {
files:
importFilesCount === 1 ? "file" : `${importFilesCount} files`,
})
}}
</template>
<template v-else>
{{ t("import.file_size_limit_exceeded_warning_single_file") }}
</template>
</p>
<div> <div>
<HoppButtonPrimary <HoppButtonPrimary
class="w-full" class="w-full"
:label="t('import.title')" :label="t('import.title')"
:disabled="!hasFile" :disabled="!hasFile || showFileSizeLimitExceededWarning"
@click="emit('importFromFile', fileContent)" @click="emit('importFromFile', fileContent)"
/> />
</div> </div>
@@ -51,16 +67,30 @@ defineProps<{
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const ALLOWED_FILE_SIZE_LIMIT = 10 // 10 MB
const importFilesCount = ref(0)
const hasFile = ref(false) const hasFile = ref(false)
const fileContent = ref("") const showFileSizeLimitExceededWarning = ref(false)
const fileContent = ref<string[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>() const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: "importFromFile", content: string): void (e: "importFromFile", content: string[]): void
}>() }>()
const onFileChange = () => { const onFileChange = async () => {
// Reset the state on entering the handler to avoid any stale state
if (showFileSizeLimitExceededWarning.value) {
showFileSizeLimitExceededWarning.value = false
}
if (importFilesCount.value) {
importFilesCount.value = 0
}
const inputFileToImport = inputChooseFileToImportFrom.value const inputFileToImport = inputChooseFileToImportFrom.value
if (!inputFileToImport) { if (!inputFileToImport) {
@@ -69,27 +99,52 @@ const onFileChange = () => {
} }
if (!inputFileToImport.files || inputFileToImport.files.length === 0) { if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = "" inputChooseFileToImportFrom.value = ""
hasFile.value = false hasFile.value = false
toast.show(t("action.choose_file").toString()) toast.show(t("action.choose_file").toString())
return return
} }
const reader = new FileReader() const readerPromises: Promise<string | null>[] = []
reader.onload = ({ target }) => { let totalFileSize = 0
const content = target!.result as string | null
if (!content) { for (let i = 0; i < inputFileToImport.files.length; i++) {
hasFile.value = false const file = inputFileToImport.files[i]
toast.show(t("action.choose_file").toString())
return totalFileSize += file.size / 1024 / 1024
if (totalFileSize > ALLOWED_FILE_SIZE_LIMIT) {
showFileSizeLimitExceededWarning.value = true
break
} }
fileContent.value = content const reader = new FileReader()
hasFile.value = !!content?.length readerPromises.push(
new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result as string | null)
reader.onerror = reject
reader.readAsText(file)
})
)
} }
reader.readAsText(inputFileToImport.files[0]) importFilesCount.value = readerPromises.length
const results = await Promise.allSettled(readerPromises)
const contentsArr = results
.filter((result) => result.status === "fulfilled")
.map((result) => (result as { value: string | null }).value)
.filter(Boolean) as string[]
const errors = results.filter((result) => result.status === "rejected")
if (errors.length) {
toast.error(t("error.reading_files"))
}
fileContent.value = contentsArr
hasFile.value = contentsArr.length > 0
} }
</script> </script>

View File

@@ -21,28 +21,24 @@
</span> </span>
</div> </div>
<div class="flex flex-col space-y-2 py-4"> <div class="flex flex-col space-y-2 py-4">
<span> <HoppSmartItem
<HoppSmartItem to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld" blank
blank :icon="IconChrome"
:icon="IconChrome" label="Chrome"
label="Chrome" :info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null" :active-info-icon="hasChromeExtInstalled"
:active-info-icon="hasChromeExtInstalled" outline
outline />
/> <HoppSmartItem
</span> to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
<span> blank
<HoppSmartItem :icon="IconFirefox"
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch" label="Firefox"
blank :info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
:icon="IconFirefox" :active-info-icon="hasFirefoxExtInstalled"
label="Firefox" outline
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null" />
:active-info-icon="hasFirefoxExtInstalled"
outline
/>
</span>
</div> </div>
<div class="space-y-4 py-4"> <div class="space-y-4 py-4">
<div class="flex items-center"> <div class="flex items-center">

View File

@@ -290,7 +290,12 @@ const widgets: Widget[] = [
}, },
] ]
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" type EmbedTabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "requestVariables"
type EmbedOption = { type EmbedOption = {
selectedTab: EmbedTabs selectedTab: EmbedTabs

View File

@@ -56,7 +56,12 @@ import { useI18n } from "~/composables/i18n"
const t = useI18n() const t = useI18n()
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" type EmbedTabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "requestVariables"
type EmbedOption = { type EmbedOption = {
selectedTab: EmbedTabs selectedTab: EmbedTabs

View File

@@ -173,6 +173,11 @@ const embedOptions = ref<EmbedOption>({
label: t("tab.authorization"), label: t("tab.authorization"),
enabled: false, enabled: false,
}, },
{
value: "requestVariables",
label: t("tab.variables"),
enabled: false,
},
], ],
theme: "system", theme: "system",
}) })
@@ -223,7 +228,12 @@ const currentUser = useReadonlyStream(
const step = ref(1) const step = ref(1)
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization" type EmbedTabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "requestVariables"
type EmbedOption = { type EmbedOption = {
selectedTab: EmbedTabs selectedTab: EmbedTabs
@@ -369,6 +379,11 @@ const displayCustomizeRequestModal = (
label: t("tab.authorization"), label: t("tab.authorization"),
enabled: false, enabled: false,
}, },
{
value: "requestVariables",
label: t("tab.variables"),
enabled: false,
},
], ],
theme: "system", theme: "system",
} }

View File

@@ -28,7 +28,7 @@
</button> </button>
</div> </div>
<div <div
class="flex" class="flex overflow-x-scroll"
:class="[{ 'border-b pt-2 ': !noActiveTab }, embedColours]" :class="[{ 'border-b pt-2 ': !noActiveTab }, embedColours]"
> >
<span <span
@@ -57,7 +57,12 @@ import { computed } from "vue"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
type Tabs = "params" | "bodyParams" | "headers" | "authorization" type Tabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "requestVariables"
type EmbedOption = { type EmbedOption = {
selectedTab: Tabs selectedTab: Tabs

View File

@@ -79,29 +79,27 @@ import { history, historyKeymap } from "@codemirror/commands"
import { inputTheme } from "~/helpers/editor/themes/baseTheme" import { inputTheme } from "~/helpers/editor/themes/baseTheme"
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment" import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
AggregateEnvironment,
aggregateEnvsWithSecrets$,
} from "~/newstore/environments"
import { platform } from "~/platform" import { platform } from "~/platform"
import { onClickOutside, useDebounceFn } from "@vueuse/core" import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { InspectorResult } from "~/services/inspection" import { InspectorResult } from "~/services/inspection"
import { invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import { Environment } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import IconEye from "~icons/lucide/eye" import IconEye from "~icons/lucide/eye"
import IconEyeoff from "~icons/lucide/eye-off" import IconEyeoff from "~icons/lucide/eye-off"
import { CompletionContext, autocompletion } from "@codemirror/autocomplete"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { syntaxTree } from "@codemirror/language"
const t = useI18n() const t = useI18n()
type Env = Environment["variables"][number] & { source: string }
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue?: string modelValue?: string
placeholder?: string placeholder?: string
styles?: string styles?: string
envs?: Env[] | null envs?: AggregateEnvironment[] | null
focus?: boolean focus?: boolean
selectTextOnMount?: boolean selectTextOnMount?: boolean
environmentHighlights?: boolean environmentHighlights?: boolean
@@ -110,6 +108,7 @@ const props = withDefaults(
inspectionResults?: InspectorResult[] | undefined inspectionResults?: InspectorResult[] | undefined
contextMenuEnabled?: boolean contextMenuEnabled?: boolean
secret?: boolean secret?: boolean
autoCompleteEnv?: boolean
}>(), }>(),
{ {
modelValue: "", modelValue: "",
@@ -124,6 +123,7 @@ const props = withDefaults(
inspectionResults: undefined, inspectionResults: undefined,
contextMenuEnabled: true, contextMenuEnabled: true,
secret: false, secret: false,
autoCompleteEnvSource: false,
} }
) )
@@ -353,29 +353,62 @@ watch(
let clipboardEv: ClipboardEvent | null = null let clipboardEv: ClipboardEvent | null = null
let pastedValue: string | null = null let pastedValue: string | null = null
const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref< const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
AggregateEnvironment[] AggregateEnvironment[]
> >
const tabs = useService(RESTTabService)
const envVars = computed(() => { const envVars = computed(() => {
return props.envs if (props.envs) {
? props.envs.map((x) => { return props.envs.map((x) => {
if (x.secret) { const { key, secret } = x
return { const value = secret ? "********" : x.value
key: x.key, const sourceEnv = "sourceEnv" in x ? x.sourceEnv : null
sourceEnv: "source" in x ? x.source : null, return {
value: "********", key,
} value,
} sourceEnv,
return { secret,
key: x.key, }
value: x.value, })
sourceEnv: "source" in x ? x.source : null, }
} return [
}) ...tabs.currentActiveTab.value.document.request.requestVariables.map(
: aggregateEnvs.value ({ active, key, value }) =>
active
? {
key,
value,
sourceEnv: "RequestVariable",
secret: false,
}
: ({} as AggregateEnvironment)
),
...aggregateEnvs.value,
]
}) })
function envAutoCompletion(context: CompletionContext) {
const options = (envVars.value ?? [])
.map((env) => ({
label: env?.key ? `<<${env.key}>>` : "",
info: env?.value ?? "",
apply: env?.key ? `<<${env.key}>>` : "",
}))
.filter((x) => x)
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1)
const textBefore = context.state.sliceDoc(nodeBefore.from, context.pos)
const tagBefore = /<<\w*$/.exec(textBefore)
if (!tagBefore && !context.explicit) return null
return {
from: tagBefore ? nodeBefore.from + tagBefore.index : context.pos,
options: options,
validFor: /^(<<\w*)?$/,
}
}
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view) const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
function handleTextSelection() { function handleTextSelection() {
@@ -384,7 +417,9 @@ function handleTextSelection() {
const { from, to } = selection const { from, to } = selection
if (from === to) return if (from === to) return
const text = view.value?.state.doc.sliceString(from, to) const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from) const coords = view.value?.coordsAtPos(from)
const top = coords?.top ?? 0
const left = coords?.left ?? 0
if (text) { if (text) {
invokeAction("contextmenu.open", { invokeAction("contextmenu.open", {
position: { position: {
@@ -406,16 +441,17 @@ function handleTextSelection() {
} }
} }
const initView = (el: any) => { // Debounce to prevent double click from selecting the word
// Debounce to prevent double click from selecting the word const debouncedTextSelection = (time: number) =>
const debounceFn = useDebounceFn(() => { useDebounceFn(() => {
handleTextSelection() handleTextSelection()
}, 140) }, time)
const initView = (el: any) => {
// Only add event listeners if context menu is enabled in the component // Only add event listeners if context menu is enabled in the component
if (props.contextMenuEnabled) { if (props.contextMenuEnabled) {
el.addEventListener("mouseup", debounceFn) el.addEventListener("mouseup", debouncedTextSelection(140))
el.addEventListener("keyup", debounceFn) el.addEventListener("keyup", debouncedTextSelection(140))
} }
const extensions: Extension = getExtensions(props.readonly || isSecret.value) const extensions: Extension = getExtensions(props.readonly || isSecret.value)
@@ -465,10 +501,17 @@ const getExtensions = (readonly: boolean): Extension => {
}, },
scroll(event) { scroll(event) {
if (event.target && props.contextMenuEnabled) { if (event.target && props.contextMenuEnabled) {
handleTextSelection() // Debounce to make the performance better
debouncedTextSelection(30)()
} }
}, },
}), }),
props.autoCompleteEnv
? autocompletion({
activateOnTyping: true,
override: [envAutoCompletion],
})
: [],
ViewPlugin.fromClass( ViewPlugin.fromClass(
class { class {
update(update: ViewUpdate) { update(update: ViewUpdate) {

View File

@@ -15,6 +15,8 @@ import { computed } from "vue"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
const props = defineProps<{ const props = defineProps<{
section?: string section?: string
@@ -26,14 +28,23 @@ const t = useI18n()
const workspaceService = useService(WorkspaceService) const workspaceService = useService(WorkspaceService)
const workspace = workspaceService.currentWorkspace const workspace = workspaceService.currentWorkspace
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const currentWorkspace = computed(() => { const currentWorkspace = computed(() => {
const personalWorkspaceName = currentUser.value?.displayName
? t("workspace.personal_workspace", { name: currentUser.value.displayName })
: t("workspace.personal")
if (props.isOnlyPersonal) { if (props.isOnlyPersonal) {
return `${t("workspace.personal")}` return personalWorkspaceName
} }
if (workspace.value.type === "team") { if (workspace.value.type === "team") {
return teamWorkspaceName.value return teamWorkspaceName.value
} }
return `${t("workspace.personal")}` return personalWorkspaceName
}) })
const teamWorkspaceName = computed(() => { const teamWorkspaceName = computed(() => {

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex flex-col"> <div class="flex flex-col">
<HoppSmartItem <HoppSmartItem
label="My Workspace" :label="personalWorkspaceName"
:icon="IconUser" :icon="IconUser"
:info-icon="workspace.type === 'personal' ? IconDone : undefined" :info-icon="workspace.type === 'personal' ? IconDone : undefined"
:active-info-icon="workspace.type === 'personal'" :active-info-icon="workspace.type === 'personal'"
@@ -36,7 +36,7 @@
class="sticky top-0 z-10 mb-2 flex items-center justify-between bg-popover py-2 pl-2" class="sticky top-0 z-10 mb-2 flex items-center justify-between bg-popover py-2 pl-2"
> >
<div class="flex items-center px-2 font-semibold text-secondaryLight"> <div class="flex items-center px-2 font-semibold text-secondaryLight">
{{ t("team.title") }} {{ t("workspace.other_workspaces") }}
</div> </div>
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -96,6 +96,12 @@ const currentUser = useReadonlyStream(
platform.auth.getProbableUser() platform.auth.getProbableUser()
) )
const personalWorkspaceName = computed(() =>
currentUser.value?.displayName
? t("workspace.personal_workspace", { name: currentUser.value.displayName })
: t("workspace.personal")
)
const workspaceService = useService(WorkspaceService) const workspaceService = useService(WorkspaceService)
const teamListadapter = workspaceService.acquireTeamListAdapter(null) const teamListadapter = workspaceService.acquireTeamListAdapter(null)
const myTeams = useReadonlyStream(teamListadapter.teamList$, []) const myTeams = useReadonlyStream(teamListadapter.teamList$, [])

View File

@@ -242,8 +242,10 @@ export function useCodemirror(
const { from, to } = selection const { from, to } = selection
if (from === to) return if (from === to) return
const text = view.value?.state.doc.sliceString(from, to) const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from) const coords = view.value?.coordsAtPos(from)
if (text) { const top = coords?.top ?? 0
const left = coords?.left ?? 0
if (text?.trim()) {
invokeAction("contextmenu.open", { invokeAction("contextmenu.open", {
position: { position: {
top, top,
@@ -263,6 +265,12 @@ export function useCodemirror(
} }
} }
// Debounce to prevent double click from selecting the word
const debouncedTextSelection = (time: number) =>
useDebounceFn(() => {
handleTextSelection()
}, time)
const initView = (el: any) => { const initView = (el: any) => {
if (el) platform.ui?.onCodemirrorInstanceMount?.(el) if (el) platform.ui?.onCodemirrorInstanceMount?.(el)
@@ -274,15 +282,10 @@ export function useCodemirror(
ViewPlugin.fromClass( ViewPlugin.fromClass(
class { class {
update(update: ViewUpdate) { update(update: ViewUpdate) {
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
// Only add event listeners if context menu is enabled in the editor // Only add event listeners if context menu is enabled in the editor
if (options.contextMenuEnabled) { if (options.contextMenuEnabled) {
el.addEventListener("mouseup", debounceFn) el.addEventListener("mouseup", debouncedTextSelection(140))
el.addEventListener("keyup", debounceFn) el.addEventListener("keyup", debouncedTextSelection(140))
} }
if (options.onUpdate) { if (options.onUpdate) {
@@ -324,7 +327,8 @@ export function useCodemirror(
EditorView.domEventHandlers({ EditorView.domEventHandlers({
scroll(event) { scroll(event) {
if (event.target && options.contextMenuEnabled) { if (event.target && options.contextMenuEnabled) {
handleTextSelection() // Debounce to make the performance better
debouncedTextSelection(30)()
} }
}, },
}), }),

View File

@@ -1,293 +0,0 @@
import {
FormDataKeyValue,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTParam,
HoppRESTReqBody,
HoppRESTRequest,
RESTReqSchemaVersion,
ValidContentTypes,
} from "@hoppscotch/data"
import { BehaviorSubject, combineLatest, map } from "rxjs"
import { applyBodyTransition } from "~/helpers/rules/BodyTransition"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
export class RESTRequest {
public v$ = new BehaviorSubject<typeof RESTReqSchemaVersion>(
RESTReqSchemaVersion
)
public name$ = new BehaviorSubject("Untitled")
public endpoint$ = new BehaviorSubject("https://echo.hoppscotch.io/")
public params$ = new BehaviorSubject<HoppRESTParam[]>([])
public headers$ = new BehaviorSubject<HoppRESTHeader[]>([])
public method$ = new BehaviorSubject("GET")
public auth$ = new BehaviorSubject<HoppRESTAuth>({
authType: "none",
authActive: true,
})
public preRequestScript$ = new BehaviorSubject("")
public testScript$ = new BehaviorSubject("")
public body$ = new BehaviorSubject<HoppRESTReqBody>({
contentType: null,
body: null,
})
public response$ = new BehaviorSubject<HoppRESTResponse | null>(null)
get request$() {
// any of above changes construct requests
return combineLatest([
this.v$,
this.name$,
this.endpoint$,
this.params$,
this.headers$,
this.method$,
this.auth$,
this.preRequestScript$,
this.testScript$,
this.body$,
]).pipe(
map(
([
v,
name,
endpoint,
params,
headers,
method,
auth,
preRequestScript,
testScript,
body,
]) => ({
v,
name,
endpoint,
params,
headers,
method,
auth,
preRequestScript,
testScript,
body,
})
)
)
}
get contentType$() {
return this.body$.pipe(map((body) => body.contentType))
}
get bodyContent$() {
return this.body$.pipe(map((body) => body.body))
}
get headersCount$() {
return this.headers$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== ""))
.length
)
)
}
get paramsCount$() {
return this.params$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== ""))
.length
)
)
}
setName(name: string) {
this.name$.next(name)
}
setEndpoint(newURL: string) {
this.endpoint$.next(newURL)
}
setMethod(newMethod: string) {
this.method$.next(newMethod)
}
setParams(entries: HoppRESTParam[]) {
this.params$.next(entries)
}
addParam(newParam: HoppRESTParam) {
const newParams = this.params$.value.concat(newParam)
this.params$.next(newParams)
}
updateParam(index: number, updatedParam: HoppRESTParam) {
const newParams = this.params$.value.map((param, i) =>
i === index ? updatedParam : param
)
this.params$.next(newParams)
}
deleteParam(index: number) {
const newParams = this.params$.value.filter((_, i) => i !== index)
this.params$.next(newParams)
}
deleteAllParams() {
this.params$.next([])
}
setHeaders(entries: HoppRESTHeader[]) {
this.headers$.next(entries)
}
addHeader(newHeader: HoppRESTHeader) {
const newHeaders = this.headers$.value.concat(newHeader)
this.headers$.next(newHeaders)
}
updateHeader(index: number, updatedHeader: HoppRESTHeader) {
const newHeaders = this.headers$.value.map((header, i) =>
i === index ? updatedHeader : header
)
this.headers$.next(newHeaders)
}
deleteHeader(index: number) {
const newHeaders = this.headers$.value.filter((_, i) => i !== index)
this.headers$.next(newHeaders)
}
deleteAllHeaders() {
this.headers$.next([])
}
setContentType(newContentType: ValidContentTypes | null) {
// TODO: persist body evenafter switching content typees
this.body$.next(applyBodyTransition(this.body$.value, newContentType))
}
setBody(newBody: string | FormDataKeyValue[] | null) {
const body = { ...this.body$.value }
body.body = newBody
this.body$.next({ ...body })
}
addFormDataEntry(entry: FormDataKeyValue) {
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [...this.body$.value.body, entry],
}
this.body$.next(body)
}
deleteFormDataEntry(index: number) {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [...this.body$.value.body.filter((_, i) => i !== index)],
}
this.body$.next(body)
}
updateFormDataEntry(index: number, entry: FormDataKeyValue) {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [
...this.body$.value.body.map((oldEntry, i) =>
i === index ? entry : oldEntry
),
],
}
this.body$.next(body)
}
deleteAllFormDataEntries() {
// Only perform update if the current content-type is formdata
if (this.body$.value.contentType !== "multipart/form-data") return {}
const body: HoppRESTReqBody = {
contentType: "multipart/form-data",
body: [],
}
this.body$.next(body)
}
setRequestBody(newBody: HoppRESTReqBody) {
this.body$.next(newBody)
}
setAuth(newAuth: HoppRESTAuth) {
this.auth$.next(newAuth)
}
setPreRequestScript(newScript: string) {
this.preRequestScript$.next(newScript)
}
setTestScript(newScript: string) {
this.testScript$.next(newScript)
}
updateResponse(response: HoppRESTResponse | null) {
this.response$.next(response)
}
setRequest(request: HoppRESTRequest) {
this.v$.next(RESTReqSchemaVersion)
this.name$.next(request.name)
this.endpoint$.next(request.endpoint)
this.params$.next(request.params)
this.headers$.next(request.headers)
this.method$.next(request.method)
this.auth$.next(request.auth)
this.preRequestScript$.next(request.preRequestScript)
this.testScript$.next(request.testScript)
this.body$.next(request.body)
}
getRequest() {
return {
v: this.v$.value,
name: this.name$.value,
endpoint: this.endpoint$.value,
params: this.params$.value,
headers: this.headers$.value,
method: this.method$.value,
auth: this.auth$.value,
preRequestScript: this.preRequestScript$.value,
testScript: this.testScript$.value,
body: this.body$.value,
}
}
resetRequest() {
this.v$.next(RESTReqSchemaVersion)
this.name$.next("")
this.endpoint$.next("")
this.params$.next([])
this.headers$.next([])
this.method$.next("GET")
this.auth$.next({
authType: "none",
authActive: false,
})
this.preRequestScript$.next("")
this.testScript$.next("")
this.body$.next({
contentType: null,
body: null,
})
}
}

View File

@@ -1,4 +1,8 @@
import { Environment } from "@hoppscotch/data" import {
Environment,
HoppRESTHeaders,
HoppRESTRequestVariable,
} from "@hoppscotch/data"
import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox" import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox"
import { runTestScript } from "@hoppscotch/js-sandbox/web" import { runTestScript } from "@hoppscotch/js-sandbox/web"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
@@ -65,10 +69,17 @@ const getTestableBody = (
return x return x
} }
const combineEnvVariables = (envs: { const combineEnvVariables = (variables: {
global: Environment["variables"] environments: {
selected: Environment["variables"] selected: Environment["variables"]
}) => [...envs.selected, ...envs.global] global: Environment["variables"]
}
requestVariables: Environment["variables"]
}) => [
...variables.requestVariables,
...variables.environments.selected,
...variables.environments.global,
]
export const executedResponses$ = new Subject< export const executedResponses$ = new Subject<
HoppRESTResponse & { type: "success" | "fail " } HoppRESTResponse & { type: "success" | "fail " }
@@ -122,6 +133,38 @@ const updateEnvironmentsWithSecret = (
return updatedEnv return updatedEnv
} }
/**
* Transforms the environment list to a list with unique keys with value
* @param envs The environment list to be transformed
* @returns The transformed environment list with keys with value
*/
const filterNonEmptyEnvironmentVariables = (
envs: Environment["variables"]
): Environment["variables"] => {
const envsMap = new Map<string, Environment["variables"][number]>()
envs.forEach((env) => {
if (env.secret) {
envsMap.set(env.key, env)
} else if (envsMap.has(env.key)) {
const existingEnv = envsMap.get(env.key)
if (
existingEnv &&
"value" in existingEnv &&
existingEnv.value === "" &&
env.value !== ""
) {
envsMap.set(env.key, env)
}
} else {
envsMap.set(env.key, env)
}
})
return Array.from(envsMap.values())
}
export function runRESTRequest$( export function runRESTRequest$(
tab: Ref<HoppTab<HoppRESTDocument>> tab: Ref<HoppTab<HoppRESTDocument>>
): [ ): [
@@ -175,15 +218,40 @@ export function runRESTRequest$(
requestHeaders = [...tab.value.document.request.headers] requestHeaders = [...tab.value.document.request.headers]
} }
const finalRequestVariables =
tab.value.document.request.requestVariables.map(
(v: HoppRESTRequestVariable) => {
if (v.active) {
return {
key: v.key,
value: v.value,
secret: false,
}
}
return []
}
)
const finalRequest = { const finalRequest = {
...tab.value.document.request, ...tab.value.document.request,
auth: requestAuth ?? { authType: "none", authActive: false }, auth: requestAuth ?? { authType: "none", authActive: false },
headers: requestHeaders, headers: requestHeaders as HoppRESTHeaders,
} }
const finalEnvs = {
requestVariables: finalRequestVariables as Environment["variables"],
environments: envs.right,
}
const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables(
combineEnvVariables(finalEnvs)
)
const effectiveRequest = getEffectiveRESTRequest(finalRequest, { const effectiveRequest = getEffectiveRESTRequest(finalRequest, {
id: "env-id",
v: 1,
name: "Env", name: "Env",
variables: combineEnvVariables(envs.right), variables: finalEnvsWithNonEmptyValues,
}) })
const [stream, cancelRun] = createRESTNetworkRequestStream(effectiveRequest) const [stream, cancelRun] = createRESTNetworkRequestStream(effectiveRequest)

View File

@@ -38,6 +38,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -141,6 +142,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -158,6 +160,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -181,6 +184,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -209,6 +213,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -239,6 +244,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -268,6 +274,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -291,6 +298,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -312,6 +320,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -335,6 +344,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -366,6 +376,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -430,6 +441,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -458,6 +470,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -515,6 +528,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -560,6 +574,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -607,6 +622,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -621,6 +637,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -645,6 +662,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -662,6 +680,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -685,6 +704,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -702,6 +722,7 @@ const samples = [
params: [], params: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -725,6 +746,7 @@ const samples = [
], ],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -747,6 +769,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -767,6 +790,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -788,6 +812,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -808,6 +833,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
{ {
@@ -839,6 +865,7 @@ const samples = [
headers: [], headers: [],
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: [],
}), }),
}, },
] ]

View File

@@ -181,5 +181,6 @@ export const parseCurlCommand = (curlCommand: string) => {
testScript: defaultRESTReq.testScript, testScript: defaultRESTReq.testScript,
auth, auth,
body: finalBody, body: finalBody,
requestVariables: defaultRESTReq.requestVariables,
}) })
} }

View File

@@ -13,7 +13,7 @@ import { StreamSubscriberFunc } from "@composables/stream"
import { import {
AggregateEnvironment, AggregateEnvironment,
aggregateEnvsWithSecrets$, aggregateEnvsWithSecrets$,
getAggregateEnvsWithSecrets, getAggregateEnvs,
getCurrentEnvironment, getCurrentEnvironment,
getSelectedEnvironmentType, getSelectedEnvironmentType,
} from "~/newstore/environments" } from "~/newstore/environments"
@@ -23,15 +23,45 @@ import IconUsers from "~icons/lucide/users?raw"
import IconEdit from "~icons/lucide/edit?raw" import IconEdit from "~icons/lucide/edit?raw"
import { SecretEnvironmentService } from "~/services/secret-environment.service" import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { getService } from "~/modules/dioc" import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
const HOPP_ENV_HIGHLIGHT = const HOPP_ENV_HIGHLIGHT =
"cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight"
const HOPP_ENV_HIGHLIGHT_FOUND = "env-found"
const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "env-not-found" const HOPP_REQUEST_VARIABLE_HIGHLIGHT = "request-variable-highlight"
const HOPP_ENVIRONMENT_HIGHLIGHT = "environment-variable-highlight"
const HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT = "global-variable-highlight"
const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "environment-not-found-highlight"
const secretEnvironmentService = getService(SecretEnvironmentService) const secretEnvironmentService = getService(SecretEnvironmentService)
const restTabs = getService(RESTTabService)
/**
* Transforms the environment list to a list with unique keys with value
* @param envs The environment list to be transformed
* @returns The transformed environment list with keys with value
*/
const filterNonEmptyEnvironmentVariables = (
envs: AggregateEnvironment[]
): AggregateEnvironment[] => {
const envsMap = new Map<string, AggregateEnvironment>()
envs.forEach((env) => {
if (envsMap.has(env.key)) {
const existingEnv = envsMap.get(env.key)
if (existingEnv?.value === "" && env.value !== "") {
envsMap.set(env.key, env)
}
} else {
envsMap.set(env.key, env)
}
})
return Array.from(envsMap.values())
}
const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
hoverTooltip( hoverTooltip(
@@ -67,7 +97,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const parsedEnvKey = text.slice(start - from, end - from) const parsedEnvKey = text.slice(start - from, end - from)
const tooltipEnv = aggregateEnvs.find((env) => env.key === parsedEnvKey) const envsWithNoEmptyValues =
filterNonEmptyEnvironmentVariables(aggregateEnvs)
const tooltipEnv = envsWithNoEmptyValues.find(
(env) => env.key === parsedEnvKey
)
const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment" const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment"
@@ -95,7 +130,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const result = parseTemplateStringE(envValue, aggregateEnvs) const result = parseTemplateStringE(envValue, aggregateEnvs)
const finalEnv = E.isLeft(result) ? "error" : result.right let finalEnv = E.isLeft(result) ? "error" : result.right
// If the request variable has an secret variable
// parseTemplateStringE is passed the secret value which has value undefined
// So, we need to check if the result is undefined and then set the finalEnv to ******
if (finalEnv === "undefined") finalEnv = "******"
const selectedEnvType = getSelectedEnvironmentType() const selectedEnvType = getSelectedEnvironmentType()
@@ -123,11 +163,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
invokeActionType = "modals.my.environment.edit" invokeActionType = "modals.my.environment.edit"
} }
invokeAction(invokeActionType, { if (tooltipEnv?.sourceEnv === "RequestVariable") {
envName: tooltipEnv?.sourceEnv !== "Global" ? envName : "Global", restTabs.currentActiveTab.value.document.optionTabPreference =
variableName: parsedEnvKey, "requestVariables"
isSecret: tooltipEnv?.secret, } else {
}) invokeAction(invokeActionType, {
envName: tooltipEnv?.sourceEnv === "Global" ? "Global" : envName,
variableName: parsedEnvKey,
isSecret: tooltipEnv?.secret,
})
}
}) })
editIcon.innerHTML = `<span class="inline-flex items-center justify-center my-1">${IconEdit}</span>` editIcon.innerHTML = `<span class="inline-flex items-center justify-center my-1">${IconEdit}</span>`
tooltip.appendChild(editIcon) tooltip.appendChild(editIcon)
@@ -165,11 +210,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
) )
function checkEnv(env: string, aggregateEnvs: AggregateEnvironment[]) { function checkEnv(env: string, aggregateEnvs: AggregateEnvironment[]) {
const className = aggregateEnvs.find( let className = HOPP_ENV_HIGHLIGHT_NOT_FOUND
const envSource = aggregateEnvs.find(
(k: { key: string }) => k.key === env.slice(2, -2) (k: { key: string }) => k.key === env.slice(2, -2)
) )?.sourceEnv
? HOPP_ENV_HIGHLIGHT_FOUND
: HOPP_ENV_HIGHLIGHT_NOT_FOUND if (envSource === "RequestVariable")
className = HOPP_REQUEST_VARIABLE_HIGHLIGHT
else if (envSource === "Global") className = HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT
else if (envSource !== undefined) className = HOPP_ENVIRONMENT_HIGHLIGHT
return Decoration.mark({ return Decoration.mark({
class: `${HOPP_ENV_HIGHLIGHT} ${className}`, class: `${HOPP_ENV_HIGHLIGHT} ${className}`,
@@ -185,7 +235,10 @@ const getMatchDecorator = (aggregateEnvs: AggregateEnvironment[]) =>
export const environmentHighlightStyle = ( export const environmentHighlightStyle = (
aggregateEnvs: AggregateEnvironment[] aggregateEnvs: AggregateEnvironment[]
) => { ) => {
const decorator = getMatchDecorator(aggregateEnvs) const envsWithNoEmptyValues =
filterNonEmptyEnvironmentVariables(aggregateEnvs)
const decorator = getMatchDecorator(envsWithNoEmptyValues)
return ViewPlugin.define( return ViewPlugin.define(
(view) => ({ (view) => ({
@@ -209,10 +262,45 @@ export class HoppEnvironmentPlugin {
subscribeToStream: StreamSubscriberFunc, subscribeToStream: StreamSubscriberFunc,
private editorView: Ref<EditorView | undefined> private editorView: Ref<EditorView | undefined>
) { ) {
this.envs = getAggregateEnvsWithSecrets() const aggregateEnvs = getAggregateEnvs()
const currentTab = restTabs.currentActiveTab.value
watch(
currentTab.document.request,
(reqVariables) => {
this.envs = [
...reqVariables.requestVariables.map(({ key, value }) => ({
key,
value,
sourceEnv: "RequestVariable",
secret: false,
})),
...aggregateEnvs,
]
this.editorView.value?.dispatch({
effects: this.compartment.reconfigure([
cursorTooltipField(this.envs),
environmentHighlightStyle(this.envs),
]),
})
},
{ immediate: true, deep: true }
)
subscribeToStream(aggregateEnvsWithSecrets$, (envs) => { subscribeToStream(aggregateEnvsWithSecrets$, (envs) => {
this.envs = envs this.envs = [
...currentTab.document.request.requestVariables.map(
({ key, value }) => ({
key,
value,
sourceEnv: "RequestVariable",
secret: false,
})
),
...envs,
]
this.editorView.value?.dispatch({ this.editorView.value?.dispatch({
effects: this.compartment.reconfigure([ effects: this.compartment.reconfigure([
cursorTooltipField(this.envs), cursorTooltipField(this.envs),
@@ -251,7 +339,7 @@ export class HoppReactiveEnvPlugin {
]), ]),
}) })
}, },
{ immediate: true } { immediate: true, deep: true }
) )
} }

View File

@@ -2,18 +2,22 @@ import { pipe, flow } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray" import * as RA from "fp-ts/ReadonlyArray"
import * as A from "fp-ts/Array"
import { translateToNewRESTCollection, HoppCollection } from "@hoppscotch/data" import { translateToNewRESTCollection, HoppCollection } from "@hoppscotch/data"
import { isPlainObject as _isPlainObject } from "lodash-es"
import { IMPORTER_INVALID_FILE_FORMAT } from "." import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json" import { safeParseJSON } from "~/helpers/functional/json"
import { translateToNewGQLCollection } from "@hoppscotch/data" import { translateToNewGQLCollection } from "@hoppscotch/data"
import { entityReference } from "verzod"
import { z } from "zod"
export const hoppRESTImporter = (content: string) => export const hoppRESTImporter = (content: string[]) =>
pipe( pipe(
safeParseJSON(content), content,
A.traverse(O.Applicative)((str) => safeParseJSON(str, true)),
O.chain( O.chain(
flow( flow(
A.flatten,
makeCollectionsArray, makeCollectionsArray,
RA.map(validateCollection), RA.map(validateCollection),
O.sequenceArray, O.sequenceArray,
@@ -23,26 +27,14 @@ export const hoppRESTImporter = (content: string) =>
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
) )
/**
* checks if a value is a plain object
*/
const isPlainObject = (value: any): value is object => _isPlainObject(value)
/**
* checks if a collection matches the schema for a hoppscotch collection.
* here 2 is the latest version of the schema.
*/
const isValidCollection = (collection: unknown): collection is HoppCollection =>
isPlainObject(collection) && "v" in collection && collection.v === 2
/** /**
* checks if a collection is a valid hoppscotch collection. * checks if a collection is a valid hoppscotch collection.
* else translate it into one. * else translate it into one.
*/ */
const validateCollection = (collection: unknown) => { const validateCollection = (collection: unknown) => {
if (isValidCollection(collection)) { const result = entityReference(HoppCollection).safeParse(collection)
return O.some(collection) if (result.success) return O.some(result.data)
}
return O.some(translateToNewRESTCollection(collection)) return O.some(translateToNewRESTCollection(collection))
} }
@@ -72,8 +64,9 @@ export const hoppGQLImporter = (content: string) =>
* @returns the collection if it is valid, else a translated version of the collection * @returns the collection if it is valid, else a translated version of the collection
*/ */
export const validateGQLCollection = (collection: unknown) => { export const validateGQLCollection = (collection: unknown) => {
if (isValidCollection(collection)) { const result = z.array(entityReference(HoppCollection)).safeParse(collection)
return O.some(collection)
} if (result.success) return O.some(result.data)
return O.some(translateToNewGQLCollection(collection)) return O.some(translateToNewGQLCollection(collection))
} }

View File

@@ -8,17 +8,35 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { Environment } from "@hoppscotch/data" import { Environment } from "@hoppscotch/data"
import { z } from "zod" import { z } from "zod"
export const hoppEnvImporter = (content: string) => { export const hoppEnvImporter = (contents: string[]) => {
const parsedContent = safeParseJSON(content, true) const parsedContents = contents.map((str) => safeParseJSON(str, true))
// parse json from the environments string if (parsedContents.some((parsed) => O.isNone(parsed))) {
if (O.isNone(parsedContent)) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)
} }
const parsedValues = parsedContents.flatMap((content) => {
const unwrappedContent = O.toNullable(content) as Environment[] | null
if (unwrappedContent) {
return unwrappedContent.map((contentEntry) => {
return {
...contentEntry,
variables: contentEntry.variables?.map((valueEntry) => ({
...valueEntry,
...("value" in valueEntry
? { value: String(valueEntry.value) }
: {}),
})),
}
})
}
return null
})
const validationResult = z const validationResult = z
.array(entityReference(Environment)) .array(entityReference(Environment))
.safeParse(parsedContent.value) .safeParse(parsedValues)
if (!validationResult.success) { if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)

View File

@@ -3,10 +3,10 @@ import * as E from "fp-ts/Either"
// TODO: add zod validation // TODO: add zod validation
export const hoppGqlCollectionsImporter = ( export const hoppGqlCollectionsImporter = (
content: string contents: string[]
): E.Either<"INVALID_JSON", HoppCollection[]> => { ): E.Either<"INVALID_JSON", HoppCollection[]> => {
return E.tryCatch( return E.tryCatch(
() => JSON.parse(content) as HoppCollection[], () => contents.flatMap((content) => JSON.parse(content)),
() => "INVALID_JSON" () => "INVALID_JSON"
) )
} }

View File

@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid"
export function FileSource(metadata: { export function FileSource(metadata: {
acceptedFileTypes: string acceptedFileTypes: string
caption: string caption: string
onImportFromFile: (content: string) => any | Promise<any> onImportFromFile: (content: string[]) => any | Promise<any>
}) { }) {
const stepID = uuidv4() const stepID = uuidv4()

View File

@@ -10,14 +10,14 @@ import { v4 as uuidv4 } from "uuid"
export function GistSource(metadata: { export function GistSource(metadata: {
caption: string caption: string
onImportFromGist: ( onImportFromGist: (
importResult: E.Either<string, string> importResult: E.Either<string, string[]>
) => any | Promise<any> ) => any | Promise<any>
}) { }) {
const stepID = uuidv4() const stepID = uuidv4()
return defineStep(stepID, UrlImport, () => ({ return defineStep(stepID, UrlImport, () => ({
caption: metadata.caption, caption: metadata.caption,
onImportFromURL: (gistResponse) => { onImportFromURL: (gistResponse: Record<string, unknown>) => {
const fileSchema = z.object({ const fileSchema = z.object({
files: z.record(z.object({ content: z.string() })), files: z.record(z.object({ content: z.string() })),
}) })
@@ -29,9 +29,11 @@ export function GistSource(metadata: {
return return
} }
const content = Object.values(parseResult.data.files)[0].content const contents = Object.values(parseResult.data.files).map(
({ content }) => content
)
metadata.onImportFromGist(E.right(content)) metadata.onImportFromGist(E.right(contents))
}, },
fetchLogic: fetchGistFromUrl, fetchLogic: fetchGistFromUrl,
})) }))

View File

@@ -1,19 +1,22 @@
import { convert, ImportRequest } from "insomnia-importers"
import { pipe } from "fp-ts/function"
import { import {
HoppCollection,
HoppRESTAuth, HoppRESTAuth,
HoppRESTHeader, HoppRESTHeader,
HoppRESTParam, HoppRESTParam,
HoppRESTReqBody, HoppRESTReqBody,
HoppRESTRequest, HoppRESTRequest,
knownContentTypes, knownContentTypes,
makeRESTRequest,
HoppCollection,
makeCollection, makeCollection,
makeRESTRequest,
HoppRESTRequestVariable,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
import * as TO from "fp-ts/TaskOption"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as TO from "fp-ts/TaskOption"
import { pipe } from "fp-ts/function"
import { ImportRequest, convert } from "insomnia-importers"
import { IMPORTER_INVALID_FILE_FORMAT } from "." import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { replaceInsomniaTemplating } from "./insomniaEnv" import { replaceInsomniaTemplating } from "./insomniaEnv"
@@ -26,14 +29,27 @@ type UnwrapPromise<T extends Promise<any>> = T extends Promise<infer Y>
type InsomniaDoc = UnwrapPromise<ReturnType<typeof convert>> type InsomniaDoc = UnwrapPromise<ReturnType<typeof convert>>
type InsomniaResource = ImportRequest type InsomniaResource = ImportRequest
// insomnia-importers v3.6.0 doesn't provide a type for path parameters and they have deprecated the library
type InsomniaPathParameter = {
name: string
value: string
}
type InsomniaFolderResource = ImportRequest & { _type: "request_group" } type InsomniaFolderResource = ImportRequest & { _type: "request_group" }
type InsomniaRequestResource = ImportRequest & { _type: "request" } type InsomniaRequestResource = ImportRequest & {
_type: "request"
} & {
pathParameters?: InsomniaPathParameter[]
}
const parseInsomniaDoc = (content: string) => const parseInsomniaDoc = (content: string) =>
TO.tryCatch(() => convert(content)) TO.tryCatch(() => convert(content))
const replacePathVarTemplating = (expression: string) =>
expression.replaceAll(/:([^/]+)/g, "<<$1>>")
const replaceVarTemplating = (expression: string) => const replaceVarTemplating = (expression: string) =>
replaceInsomniaTemplating(expression) pipe(expression, replacePathVarTemplating, replaceInsomniaTemplating)
const getFoldersIn = ( const getFoldersIn = (
folder: InsomniaFolderResource | null, folder: InsomniaFolderResource | null,
@@ -175,6 +191,15 @@ const getHoppReqParams = (req: InsomniaRequestResource): HoppRESTParam[] =>
active: !(param.disabled ?? false), active: !(param.disabled ?? false),
})) ?? [] })) ?? []
const getHoppReqVariables = (
req: InsomniaRequestResource
): HoppRESTRequestVariable[] =>
req.pathParameters?.map((variable) => ({
key: replaceVarTemplating(variable.name),
value: replaceVarTemplating(variable.value ?? ""),
active: true,
})) ?? []
const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest => const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest =>
makeRESTRequest({ makeRESTRequest({
name: req.name ?? "Untitled Request", name: req.name ?? "Untitled Request",
@@ -187,6 +212,8 @@ const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest =>
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
requestVariables: getHoppReqVariables(req),
}) })
const getHoppFolder = ( const getHoppFolder = (
@@ -203,15 +230,18 @@ const getHoppFolder = (
headers: [], headers: [],
}) })
const getHoppCollections = (doc: InsomniaDoc) => const getHoppCollections = (docs: InsomniaDoc[]) => {
getFoldersIn(null, doc.data.resources).map((f) => return docs.flatMap((doc) => {
getHoppFolder(f, doc.data.resources) return getFoldersIn(null, doc.data.resources).map((f) =>
) getHoppFolder(f, doc.data.resources)
)
})
}
export const hoppInsomniaImporter = (fileContent: string) => export const hoppInsomniaImporter = (fileContents: string[]) =>
pipe( pipe(
fileContent, fileContents,
parseInsomniaDoc, A.traverse(TO.ApplicativeSeq)(parseInsomniaDoc),
TO.map(getHoppCollections), TO.map(getHoppCollections),
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT) TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT)
) )

View File

@@ -29,33 +29,36 @@ export const replaceInsomniaTemplating = (expression: string) => {
return expression.replaceAll(regex, "<<$1>>") return expression.replaceAll(regex, "<<$1>>")
} }
export const insomniaEnvImporter = (content: string) => { export const insomniaEnvImporter = (contents: string[]) => {
const parsedContent = safeParseJSONOrYAML(content) const parsedContents = contents.map((str) => safeParseJSONOrYAML(str))
if (parsedContents.some((parsed) => O.isNone(parsed))) {
if (O.isNone(parsedContent)) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)
} }
const validationResult = insomniaResourcesSchema.safeParse( const parsedValues = parsedContents.map((parsed) => O.toNullable(parsed))
parsedContent.value
) const validationResult = z
.array(insomniaResourcesSchema)
.safeParse(parsedValues)
if (!validationResult.success) { if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)
} }
const insomniaEnvs = validationResult.data.resources const insomniaEnvs = validationResult.data.flatMap(({ resources }) => {
.filter((resource) => resource._type === "environment") return resources
.map((envResource) => { .filter((resource) => resource._type === "environment")
const envResourceData = envResource.data as Record<string, unknown> .map((envResource) => {
const stringifiedData: Record<string, string> = {} const envResourceData = envResource.data as Record<string, unknown>
const stringifiedData: Record<string, string> = {}
Object.keys(envResourceData).forEach((key) => { Object.keys(envResourceData).forEach((key) => {
stringifiedData[key] = String(envResourceData[key]) stringifiedData[key] = String(envResourceData[key])
})
return { ...envResource, data: stringifiedData }
}) })
})
return { ...envResource, data: stringifiedData }
})
const environments: NonSecretEnvironment[] = [] const environments: NonSecretEnvironment[] = []

View File

@@ -16,6 +16,8 @@ import {
makeRESTRequest, makeRESTRequest,
HoppCollection, HoppCollection,
makeCollection, makeCollection,
HoppRESTRequestVariable,
HoppRESTRequest,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { pipe, flow } from "fp-ts/function" import { pipe, flow } from "fp-ts/function"
import * as A from "fp-ts/Array" import * as A from "fp-ts/Array"
@@ -24,6 +26,7 @@ import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import * as RA from "fp-ts/ReadonlyArray" import * as RA from "fp-ts/ReadonlyArray"
import { IMPORTER_INVALID_FILE_FORMAT } from "." import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { cloneDeep } from "lodash-es"
export const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const export const OPENAPI_DEREF_ERROR = "openapi/deref_error" as const
@@ -82,6 +85,27 @@ const parseOpenAPIParams = (params: OpenAPIParamsType[]): HoppRESTParam[] =>
) )
) )
const parseOpenAPIVariables = (
variables: OpenAPIParamsType[]
): HoppRESTRequestVariable[] =>
pipe(
variables,
A.filterMap(
flow(
O.fromPredicate((param) => param.in === "path"),
O.map(
(param) =>
<HoppRESTRequestVariable>{
key: param.name,
value: "", // TODO: Can we do anything more ? (parse default values maybe)
active: true,
}
)
)
)
)
const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] => const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] =>
pipe( pipe(
params, params,
@@ -558,50 +582,98 @@ const convertPathToHoppReqs = (
? openAPIUrl + openAPIPath.slice(1) ? openAPIUrl + openAPIPath.slice(1)
: openAPIUrl + openAPIPath : openAPIUrl + openAPIPath
return makeRESTRequest({ const res: {
name: info.operationId ?? info.summary ?? "Untitled Request", request: HoppRESTRequest
method: method.toUpperCase(), metadata: {
endpoint, tags: string[]
}
} = {
request: makeRESTRequest({
name: info.operationId ?? info.summary ?? "Untitled Request",
method: method.toUpperCase(),
endpoint,
// We don't need to worry about reference types as the Dereferencing pass should remove them // We don't need to worry about reference types as the Dereferencing pass should remove them
params: parseOpenAPIParams( params: parseOpenAPIParams(
(info.parameters as OpenAPIParamsType[] | undefined) ?? [] (info.parameters as OpenAPIParamsType[] | undefined) ?? []
), ),
headers: parseOpenAPIHeaders( headers: parseOpenAPIHeaders(
(info.parameters as OpenAPIParamsType[] | undefined) ?? [] (info.parameters as OpenAPIParamsType[] | undefined) ?? []
), ),
auth: parseOpenAPIAuth(doc, info), auth: parseOpenAPIAuth(doc, info),
body: parseOpenAPIBody(doc, info), body: parseOpenAPIBody(doc, info),
preRequestScript: "", preRequestScript: "",
testScript: "", testScript: "",
})
requestVariables: parseOpenAPIVariables(
(info.parameters as OpenAPIParamsType[] | undefined) ?? []
),
}),
metadata: {
tags: info.tags ?? [],
},
}
return res
}), }),
// Disable Readonly // Disable Readonly
RA.toArray RA.toArray
) )
const convertOpenApiDocToHopp = ( const convertOpenApiDocsToHopp = (
doc: OpenAPI.Document docs: OpenAPI.Document[]
): TE.TaskEither<never, HoppCollection[]> => { ): TE.TaskEither<never, HoppCollection[]> => {
const name = doc.info.title const collections = docs.map((doc) => {
const name = doc.info.title
const paths = Object.entries(doc.paths ?? {}) const paths = Object.entries(doc.paths ?? {})
.map(([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj)) .map(([pathName, pathObj]) =>
.flat() convertPathToHoppReqs(doc, pathName, pathObj)
)
.flat()
return TE.of([ const requestsByTags: Record<string, Array<HoppRESTRequest>> = {}
makeCollection({ const requestsWithoutTags: Array<HoppRESTRequest> = []
paths.forEach(({ metadata, request }) => {
const tags = metadata.tags
if (tags.length === 0) {
requestsWithoutTags.push(request)
return
}
for (const tag of tags) {
if (!requestsByTags[tag]) {
requestsByTags[tag] = []
}
requestsByTags[tag].push(cloneDeep(request))
}
})
return makeCollection({
name, name,
folders: [], folders: Object.entries(requestsByTags).map(([name, paths]) =>
requests: paths, makeCollection({
name,
requests: paths,
folders: [],
auth: { authType: "inherit", authActive: true },
headers: [],
})
),
requests: requestsWithoutTags,
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
}), })
]) })
return TE.of(collections)
} }
const parseOpenAPIDocContent = (str: string) => const parseOpenAPIDocContent = (str: string) =>
@@ -614,29 +686,49 @@ const parseOpenAPIDocContent = (str: string) =>
) )
) )
export const hoppOpenAPIImporter = (fileContent: string) => export const hoppOpenAPIImporter = (fileContents: string[]) =>
pipe( pipe(
// See if we can parse JSON properly // See if we can parse JSON properly
fileContent, fileContents,
parseOpenAPIDocContent, A.traverse(O.Applicative)(parseOpenAPIDocContent),
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT), TE.fromOption(() => {
return IMPORTER_INVALID_FILE_FORMAT
}),
// Try validating, else the importer is invalid file format // Try validating, else the importer is invalid file format
TE.chainW((obj) => TE.chainW((docArr) => {
pipe( return pipe(
TE.tryCatch( TE.tryCatch(
() => SwaggerParser.validate(obj), async () => {
const resultDoc = []
for (const docObj of docArr) {
const validatedDoc = await SwaggerParser.validate(docObj)
resultDoc.push(validatedDoc)
}
return resultDoc
},
() => IMPORTER_INVALID_FILE_FORMAT () => IMPORTER_INVALID_FILE_FORMAT
) )
) )
), }),
// Deference the references // Deference the references
TE.chainW((obj) => TE.chainW((docArr) =>
pipe( pipe(
TE.tryCatch( TE.tryCatch(
() => SwaggerParser.dereference(obj), async () => {
const resultDoc = []
for (const docObj of docArr) {
const validatedDoc = await SwaggerParser.dereference(docObj)
resultDoc.push(validatedDoc)
}
return resultDoc
},
() => OPENAPI_DEREF_ERROR () => OPENAPI_DEREF_ERROR
) )
) )
), ),
TE.chainW(convertOpenApiDocToHopp) TE.chainW(convertOpenApiDocsToHopp)
) )

View File

@@ -5,6 +5,7 @@ import {
QueryParam, QueryParam,
RequestAuthDefinition, RequestAuthDefinition,
VariableDefinition, VariableDefinition,
Variable,
} from "postman-collection" } from "postman-collection"
import { import {
HoppRESTAuth, HoppRESTAuth,
@@ -18,6 +19,7 @@ import {
ValidContentTypes, ValidContentTypes,
knownContentTypes, knownContentTypes,
FormDataKeyValue, FormDataKeyValue,
HoppRESTRequestVariable,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { pipe, flow } from "fp-ts/function" import { pipe, flow } from "fp-ts/function"
import * as S from "fp-ts/string" import * as S from "fp-ts/string"
@@ -55,7 +57,11 @@ const readPMCollection = (def: string) =>
pipe( pipe(
def, def,
safeParseJSON, safeParseJSON,
O.chain((data) => O.tryCatch(() => new PMCollection(data))) O.chain((data) =>
O.tryCatch(() => {
return new PMCollection(data)
})
)
) )
const getHoppReqHeaders = (item: Item): HoppRESTHeader[] => const getHoppReqHeaders = (item: Item): HoppRESTHeader[] =>
@@ -87,6 +93,25 @@ const getHoppReqParams = (item: Item): HoppRESTParam[] => {
) )
} }
const getHoppReqVariables = (item: Item) => {
return pipe(
item.request.url.variables.all(),
A.filter(
(variable): variable is Variable =>
variable.key !== undefined &&
variable.key !== null &&
variable.key.length > 0
),
A.map((variable) => {
return <HoppRESTRequestVariable>{
key: replacePMVarTemplating(variable.key ?? ""),
value: replacePMVarTemplating(variable.value ?? ""),
active: !variable.disabled,
}
})
)
}
type PMRequestAuthDef< type PMRequestAuthDef<
AuthType extends AuthType extends
RequestAuthDefinition["type"] = RequestAuthDefinition["type"], RequestAuthDefinition["type"] = RequestAuthDefinition["type"],
@@ -276,6 +301,7 @@ const getHoppRequest = (item: Item): HoppRESTRequest => {
params: getHoppReqParams(item), params: getHoppReqParams(item),
auth: getHoppReqAuth(item), auth: getHoppReqAuth(item),
body: getHoppReqBody(item), body: getHoppReqBody(item),
requestVariables: getHoppReqVariables(item),
// TODO: Decide about this // TODO: Decide about this
preRequestScript: "", preRequestScript: "",
@@ -296,15 +322,17 @@ const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection =>
headers: [], headers: [],
}) })
export const getHoppCollection = (coll: PMCollection) => getHoppFolder(coll) export const getHoppCollections = (collections: PMCollection[]) => {
return collections.map(getHoppFolder)
}
export const hoppPostmanImporter = (fileContent: string) => export const hoppPostmanImporter = (fileContents: string[]) =>
pipe( pipe(
// Try reading // Try reading
fileContent, fileContents,
readPMCollection, A.traverse(O.Applicative)(readPMCollection),
O.map(flow(getHoppCollection, A.of)), O.map(flow(getHoppCollections)),
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT) TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
) )

View File

@@ -1,12 +1,11 @@
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { z } from "zod"
import { Environment } from "@hoppscotch/data" import { Environment } from "@hoppscotch/data"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { uniqueId } from "lodash-es" import { uniqueId } from "lodash-es"
import { z } from "zod"
import { safeParseJSON } from "~/helpers/functional/json"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
const postmanEnvSchema = z.object({ const postmanEnvSchema = z.object({
name: z.string(), name: z.string(),
@@ -18,32 +17,44 @@ const postmanEnvSchema = z.object({
), ),
}) })
export const postmanEnvImporter = (content: string) => { type PostmanEnv = z.infer<typeof postmanEnvSchema>
const parsedContent = safeParseJSON(content)
// parse json from the environments string export const postmanEnvImporter = (contents: string[]) => {
if (O.isNone(parsedContent)) { const parsedContents = contents.map((str) => safeParseJSON(str, true))
if (parsedContents.some((parsed) => O.isNone(parsed))) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)
} }
const validationResult = postmanEnvSchema.safeParse(parsedContent.value) const parsedValues = parsedContents.flatMap((parsed) => {
const unwrappedEntry = O.toNullable(parsed) as PostmanEnv[] | null
if (unwrappedEntry) {
return unwrappedEntry.map((entry) => ({
...entry,
values: entry.values?.map((valueEntry) => ({
...valueEntry,
value: String(valueEntry.value),
})),
}))
}
return null
})
const validationResult = z.array(postmanEnvSchema).safeParse(parsedValues)
if (!validationResult.success) { if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT) return TE.left(IMPORTER_INVALID_FILE_FORMAT)
} }
const postmanEnv = validationResult.data // Convert `values` to `variables` to match the format expected by the system
const environments: Environment[] = validationResult.data.map(
const environment: Environment = { ({ name, values }) => ({
id: uniqueId(), id: uniqueId(),
v: 1, v: 1,
name: postmanEnv.name, name,
variables: [], variables: values.map((entires) => ({ ...entires, secret: false })),
} })
postmanEnv.values.forEach(({ key, value }) =>
environment.variables.push({ key, value, secret: false })
) )
return TE.right(environment) return TE.right(environments)
} }

View File

@@ -14,8 +14,8 @@ import { SecretEnvironmentService } from "~/services/secret-environment.service"
const secretEnvironmentService = getService(SecretEnvironmentService) const secretEnvironmentService = getService(SecretEnvironmentService)
const unsecretEnvironments = ( const unsecretEnvironments = (
global: Environment["variables"], selected: Environment,
selected: Environment global: Environment["variables"]
) => { ) => {
const resolvedGlobalWithSecrets = global.map((globalVar, index) => { const resolvedGlobalWithSecrets = global.map((globalVar, index) => {
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable( const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
@@ -65,8 +65,8 @@ const unsecretEnvironments = (
export const getCombinedEnvVariables = () => { export const getCombinedEnvVariables = () => {
const reformedVars = unsecretEnvironments( const reformedVars = unsecretEnvironments(
getGlobalVariables(), getCurrentEnvironment(),
getCurrentEnvironment() getGlobalVariables()
) )
return { return {
global: cloneDeep(reformedVars.global), global: cloneDeep(reformedVars.global),

View File

@@ -17,4 +17,5 @@ export const getDefaultRESTRequest = (): HoppRESTRequest => ({
contentType: null, contentType: null,
body: null, body: null,
}, },
requestVariables: [],
}) })

View File

@@ -22,7 +22,6 @@ import {
import { arrayFlatMap, arraySort } from "../functional/array" import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData" import { toFormData } from "../functional/formData"
import { tupleWithSameKeysToRecord } from "../functional/record" import { tupleWithSameKeysToRecord } from "../functional/record"
import { getGlobalVariables } from "~/newstore/environments"
export interface EffectiveHoppRESTRequest extends HoppRESTRequest { export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
/** /**
@@ -34,6 +33,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
effectiveFinalHeaders: { key: string; value: string }[] effectiveFinalHeaders: { key: string; value: string }[]
effectiveFinalParams: { key: string; value: string }[] effectiveFinalParams: { key: string; value: string }[]
effectiveFinalBody: FormData | string | null effectiveFinalBody: FormData | string | null
effectiveFinalRequestVariables: { key: string; value: string }[]
} }
/** /**
@@ -313,38 +313,53 @@ export function getEffectiveRESTRequest(
request: HoppRESTRequest, request: HoppRESTRequest,
environment: Environment environment: Environment
): EffectiveHoppRESTRequest { ): EffectiveHoppRESTRequest {
const envVariables = [...environment.variables, ...getGlobalVariables()]
const effectiveFinalHeaders = pipe( const effectiveFinalHeaders = pipe(
getComputedHeaders(request, envVariables).map((h) => h.header), getComputedHeaders(request, environment.variables).map((h) => h.header),
A.concat(request.headers), A.concat(request.headers),
A.filter((x) => x.active && x.key !== ""), A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({ A.map((x) => ({
active: true, active: true,
key: parseTemplateString(x.key, envVariables), key: parseTemplateString(x.key, environment.variables),
value: parseTemplateString(x.value, envVariables), value: parseTemplateString(x.value, environment.variables),
})) }))
) )
const effectiveFinalParams = pipe( const effectiveFinalParams = pipe(
getComputedParams(request, envVariables).map((p) => p.param), getComputedParams(request, environment.variables).map((p) => p.param),
A.concat(request.params), A.concat(request.params),
A.filter((x) => x.active && x.key !== ""), A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({ A.map((x) => ({
active: true, active: true,
key: parseTemplateString(x.key, envVariables), key: parseTemplateString(x.key, environment.variables),
value: parseTemplateString(x.value, envVariables), value: parseTemplateString(x.value, environment.variables),
})) }))
) )
const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables) const effectiveFinalRequestVariables = pipe(
request.requestVariables,
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(x.key, environment.variables),
value: parseTemplateString(x.value, environment.variables),
}))
)
const effectiveFinalBody = getFinalBodyFromRequest(
request,
environment.variables
)
return { return {
...request, ...request,
effectiveFinalURL: parseTemplateString(request.endpoint, envVariables), effectiveFinalURL: parseTemplateString(
request.endpoint,
environment.variables
),
effectiveFinalHeaders, effectiveFinalHeaders,
effectiveFinalParams, effectiveFinalParams,
effectiveFinalBody, effectiveFinalBody,
effectiveFinalRequestVariables,
} }
} }

View File

@@ -34,7 +34,7 @@ export type HoppModule = {
to: RouteLocationNormalized, to: RouteLocationNormalized,
from: RouteLocationNormalized, from: RouteLocationNormalized,
router: Router router: Router
) => void ) => void | Promise<void>
/** /**
* Called by the router to tell all the modules that a route navigation has completed * Called by the router to tell all the modules that a route navigation has completed

View File

@@ -47,15 +47,21 @@ export default <HoppModule>{
routes, routes,
}) })
router.beforeEach((to, from) => { router.beforeEach(async (to, from) => {
_isLoadingInitialRoute.value = isInitialRoute(from) _isLoadingInitialRoute.value = isInitialRoute(from)
const onBeforeRouteChangePromises: Promise<any>[] = []
HOPP_MODULES.forEach((mod) => { HOPP_MODULES.forEach((mod) => {
mod.onBeforeRouteChange?.(to, from, router) const res = mod.onBeforeRouteChange?.(to, from, router)
if (res) onBeforeRouteChangePromises.push(res)
}) })
platform.addedHoppModules?.forEach((mod) => { platform.addedHoppModules?.forEach((mod) => {
mod.onBeforeRouteChange?.(to, from, router) const res = mod.onBeforeRouteChange?.(to, from, router)
if (res) onBeforeRouteChangePromises.push(res)
}) })
await Promise.all(onBeforeRouteChangePromises)
}) })
// Instead of this a better architecture is for the router // Instead of this a better architecture is for the router

View File

@@ -61,7 +61,7 @@ export function navigateToFolderWithIndexPath(
let target = collections[indexPaths.shift() as number] let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0) while (indexPaths.length > 0 && target)
target = target.folders[indexPaths.shift() as number] target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null return target !== undefined ? target : null

Some files were not shown because too many files have changed in this diff Show More