Compare commits
39 Commits
pr/JoelJac
...
feat/reque
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e941271e | ||
|
|
e7fded89d5 | ||
|
|
40c8d7e4b7 | ||
|
|
1d93d9dabe | ||
|
|
e9f1dc7ba1 | ||
|
|
1e906bbec3 | ||
|
|
7776668e40 | ||
|
|
3d38b7678b | ||
|
|
1ca215d887 | ||
|
|
aec04a13e3 | ||
|
|
9a2058bbec | ||
|
|
1c65147a77 | ||
|
|
207eea538f | ||
|
|
e30b59e1b5 | ||
|
|
9704b3324b | ||
|
|
386bb453d4 | ||
|
|
175641246e | ||
|
|
fb615d2d2b | ||
|
|
7685409ee6 | ||
|
|
e01818444e | ||
|
|
52220d9a2e | ||
|
|
82d687f665 | ||
|
|
d44083a380 | ||
|
|
7589c57e86 | ||
|
|
c8009aec77 | ||
|
|
7114f4fd43 | ||
|
|
ca1a4ec31b | ||
|
|
359633102e | ||
|
|
b278691a9d | ||
|
|
c27e1be230 | ||
|
|
11e8ba7bb3 | ||
|
|
2a0000cfc4 | ||
|
|
0ed063724e | ||
|
|
170ec15821 | ||
|
|
3611cac241 | ||
|
|
919579b1da | ||
|
|
4798d7bbbd | ||
|
|
a0c6b22641 | ||
|
|
de8929ab18 |
@@ -112,17 +112,17 @@ services:
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||
context: .
|
||||
target: prod
|
||||
target: dev
|
||||
env_file:
|
||||
- ./.env
|
||||
restart: always
|
||||
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)
|
||||
# - 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
|
||||
volumes:
|
||||
# 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/
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
|
||||
@@ -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);
|
||||
@@ -41,31 +41,31 @@ model TeamInvitation {
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
EnableAndDisableSSOArgs,
|
||||
InfraConfigArgs,
|
||||
} 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';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@@ -274,10 +274,10 @@ export class InfraResolver {
|
||||
async infraConfigs(
|
||||
@Args({
|
||||
name: 'configNames',
|
||||
type: () => [InfraConfigEnumForClient],
|
||||
type: () => [InfraConfigEnum],
|
||||
description: 'Configs to fetch',
|
||||
})
|
||||
names: InfraConfigEnumForClient[],
|
||||
names: InfraConfigEnum[],
|
||||
) {
|
||||
const infraConfigs = await this.infraConfigService.getMany(names);
|
||||
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
|
||||
|
||||
@@ -18,12 +18,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||
import {
|
||||
AuthProvider,
|
||||
authCookieHandler,
|
||||
authProviderCheck,
|
||||
throwHTTPErr,
|
||||
} from './helper';
|
||||
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-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 { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
|
||||
@@ -12,7 +12,10 @@ import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
@@ -34,6 +37,11 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
})
|
||||
export class AuthModule {
|
||||
static async register() {
|
||||
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
|
||||
if (!isInfraConfigPopulated) {
|
||||
return { module: AuthModule };
|
||||
}
|
||||
|
||||
const env = await loadInfraConfiguration();
|
||||
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
RefreshTokenPayload,
|
||||
} from 'src/types/AuthTokens';
|
||||
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 { VerificationToken } from '@prisma/client';
|
||||
import { Origin } from './helper';
|
||||
@@ -117,7 +117,7 @@ export class AuthService {
|
||||
userUid,
|
||||
);
|
||||
if (E.isLeft(updatedUser))
|
||||
return E.left(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: updatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export class AuthService {
|
||||
*/
|
||||
async verifyMagicLinkTokens(
|
||||
magicLinkIDTokens: VerifyMagicDto,
|
||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
||||
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||
magicLinkIDTokens,
|
||||
);
|
||||
@@ -373,7 +373,7 @@ export class AuthService {
|
||||
if (usersCount === 1) {
|
||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||
if (E.isLeft(elevatedUser))
|
||||
return E.left(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftSSOGuard
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { AuthTokens } from 'src/types/AuthTokens';
|
||||
import { Response } from 'express';
|
||||
import * as cookie from 'cookie';
|
||||
@@ -25,15 +24,6 @@ export enum AuthProvider {
|
||||
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
|
||||
* @param res Express Response Object
|
||||
|
||||
@@ -17,8 +17,8 @@ export class GithubStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('GITHUB_CALLBACK_URL'),
|
||||
scope: [configService.get('GITHUB_SCOPE')],
|
||||
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.GITHUB_SCOPE')],
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
|
||||
scope: configService.get('GOOGLE_SCOPE').split(','),
|
||||
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
|
||||
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
|
||||
passReqToCallback: true,
|
||||
store: true,
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('MICROSOFT_CALLBACK_URL'),
|
||||
scope: [configService.get('MICROSOFT_SCOPE')],
|
||||
tenant: configService.get('MICROSOFT_TENANT'),
|
||||
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
|
||||
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
|
||||
export const TEAM_COL_SAME_NEXT_COLL =
|
||||
'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
|
||||
* (TeamCollectionService)
|
||||
@@ -283,6 +289,13 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
||||
export const TEAM_COLL_DATA_INVALID =
|
||||
'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
|
||||
* (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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (AuthService)
|
||||
@@ -705,6 +731,13 @@ export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
|
||||
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
|
||||
'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
|
||||
* (InfraConfigService)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { throwErr } from 'src/utils';
|
||||
@@ -14,14 +17,21 @@ const AuthProviderConfigurations = {
|
||||
[AuthProvider.GOOGLE]: [
|
||||
InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
InfraConfigEnum.GOOGLE_SCOPE,
|
||||
],
|
||||
[AuthProvider.GITHUB]: [
|
||||
InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
InfraConfigEnum.GITHUB_SCOPE,
|
||||
],
|
||||
[AuthProvider.MICROSOFT]: [
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]: [
|
||||
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
|
||||
* (Docker will re-start the app)
|
||||
|
||||
@@ -4,9 +4,9 @@ import { InfraConfigService } from './infra-config.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
|
||||
import { throwHTTPErr } from 'src/auth/helper';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'site', version: '1' })
|
||||
@@ -17,11 +17,11 @@ export class SiteController {
|
||||
@UseGuards(JwtAuthGuard, RESTAdminGuard)
|
||||
async fetchSetupInfo() {
|
||||
const status = await this.infraConfigService.get(
|
||||
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP,
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
);
|
||||
|
||||
if (E.isLeft(status))
|
||||
throwHTTPErr(<AuthError>{
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: status.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -32,13 +32,13 @@ export class SiteController {
|
||||
@UseGuards(JwtAuthGuard, RESTAdminGuard)
|
||||
async setSetupAsComplete() {
|
||||
const res = await this.infraConfigService.update(
|
||||
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP,
|
||||
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
false.toString(),
|
||||
false,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throwHTTPErr(<AuthError>{
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: res.left,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from './helper';
|
||||
|
||||
@ObjectType()
|
||||
@@ -8,7 +8,7 @@ export class InfraConfig {
|
||||
@Field({
|
||||
description: 'Infra Config Name',
|
||||
})
|
||||
name: InfraConfigEnumForClient;
|
||||
name: InfraConfigEnum;
|
||||
|
||||
@Field({
|
||||
description: 'Infra Config Value',
|
||||
@@ -16,7 +16,7 @@ export class InfraConfig {
|
||||
value: string;
|
||||
}
|
||||
|
||||
registerEnumType(InfraConfigEnumForClient, {
|
||||
registerEnumType(InfraConfigEnum, {
|
||||
name: 'InfraConfigEnum',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigService } from './infra-config.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import {
|
||||
InfraConfigEnum,
|
||||
InfraConfigEnumForClient,
|
||||
} from 'src/types/InfraConfig';
|
||||
import { INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_UPDATE_FAILED } from 'src/errors';
|
||||
INFRA_CONFIG_NOT_FOUND,
|
||||
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
|
||||
INFRA_CONFIG_UPDATE_FAILED,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as helper from './helper';
|
||||
import { InfraConfig as dbInfraConfig } from '@prisma/client';
|
||||
import { InfraConfig } from './infra-config.model';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
@@ -19,12 +22,82 @@ const infraConfigService = new InfraConfigService(
|
||||
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(() => {
|
||||
mockReset(mockPrisma);
|
||||
});
|
||||
|
||||
describe('InfraConfigService', () => {
|
||||
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 () => {
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
@@ -71,7 +144,7 @@ describe('InfraConfigService', () => {
|
||||
|
||||
describe('get', () => {
|
||||
it('should get the infra config', async () => {
|
||||
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
|
||||
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
|
||||
const value = 'true';
|
||||
|
||||
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
@@ -87,7 +160,7 @@ describe('InfraConfigService', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -98,7 +171,7 @@ describe('InfraConfigService', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -106,4 +179,45 @@ describe('InfraConfigService', () => {
|
||||
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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,28 +3,25 @@ import { InfraConfig } from './infra-config.model';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfig as DBInfraConfig } from '@prisma/client';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
InfraConfigEnum,
|
||||
InfraConfigEnumForClient,
|
||||
} from 'src/types/InfraConfig';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import {
|
||||
AUTH_PROVIDER_NOT_SPECIFIED,
|
||||
DATABASE_TABLE_NOT_EXIST,
|
||||
INFRA_CONFIG_INVALID_INPUT,
|
||||
INFRA_CONFIG_NOT_FOUND,
|
||||
INFRA_CONFIG_NOT_LISTED,
|
||||
INFRA_CONFIG_RESET_FAILED,
|
||||
INFRA_CONFIG_UPDATE_FAILED,
|
||||
INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
|
||||
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
|
||||
} from 'src/errors';
|
||||
import { throwErr, validateSMTPEmail, validateSMTPUrl } from 'src/utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ServiceStatus,
|
||||
generateAnalyticsUserId,
|
||||
getConfiguredSSOProviders,
|
||||
stopApp,
|
||||
} from './helper';
|
||||
throwErr,
|
||||
validateSMTPEmail,
|
||||
validateSMTPUrl,
|
||||
validateUrl,
|
||||
} from 'src/utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ServiceStatus, getDefaultInfraConfigs, stopApp } from './helper';
|
||||
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
|
||||
@@ -35,84 +32,32 @@ export class InfraConfigService implements OnModuleInit {
|
||||
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() {
|
||||
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
|
||||
* @description This function create rows 'infra_config' in very first time (only once)
|
||||
*/
|
||||
async initializeInfraConfigTable() {
|
||||
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
|
||||
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs();
|
||||
|
||||
// Check if all the 'names' are listed in the default values
|
||||
if (enumValues.length !== infraConfigDefaultObjs.length) {
|
||||
throw new Error(INFRA_CONFIG_NOT_LISTED);
|
||||
}
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
|
||||
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
|
||||
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
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async update(
|
||||
name: InfraConfigEnumForClient | InfraConfigEnum,
|
||||
value: string,
|
||||
restartEnabled = false,
|
||||
) {
|
||||
async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
|
||||
const isValidate = this.validateEnvValues([{ name, value }]);
|
||||
if (E.isLeft(isValidate)) return E.left(isValidate.left);
|
||||
|
||||
@@ -197,6 +138,11 @@ export class InfraConfigService implements OnModuleInit {
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
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);
|
||||
if (E.isLeft(isValidate)) return E.left(isValidate.left);
|
||||
|
||||
@@ -230,12 +176,26 @@ export class InfraConfigService implements OnModuleInit {
|
||||
) {
|
||||
switch (service) {
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
|
||||
@@ -310,7 +270,7 @@ export class InfraConfigService implements OnModuleInit {
|
||||
* @param name Name of the InfraConfig
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async get(name: InfraConfigEnumForClient) {
|
||||
async get(name: InfraConfigEnum) {
|
||||
try {
|
||||
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
|
||||
where: { name },
|
||||
@@ -327,7 +287,15 @@ export class InfraConfigService implements OnModuleInit {
|
||||
* @param names Names of the InfraConfigs
|
||||
* @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 {
|
||||
const infraConfigs = await this.prisma.infraConfig.findMany({
|
||||
where: { name: { in: names } },
|
||||
@@ -354,7 +322,7 @@ export class InfraConfigService implements OnModuleInit {
|
||||
*/
|
||||
async reset() {
|
||||
try {
|
||||
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs();
|
||||
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
|
||||
|
||||
await this.prisma.infraConfig.deleteMany({
|
||||
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
|
||||
@@ -387,36 +355,60 @@ export class InfraConfigService implements OnModuleInit {
|
||||
*/
|
||||
validateEnvValues(
|
||||
infraConfigs: {
|
||||
name: InfraConfigEnumForClient | InfraConfigEnum;
|
||||
name: InfraConfigEnum;
|
||||
value: string;
|
||||
}[],
|
||||
) {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
switch (infraConfigs[i].name) {
|
||||
case InfraConfigEnumForClient.MAILER_SMTP_URL:
|
||||
case InfraConfigEnum.MAILER_SMTP_URL:
|
||||
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
|
||||
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnumForClient.MAILER_ADDRESS_FROM:
|
||||
case InfraConfigEnum.MAILER_ADDRESS_FROM:
|
||||
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
|
||||
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnumForClient.GOOGLE_CLIENT_ID:
|
||||
case InfraConfigEnum.GOOGLE_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnumForClient.GOOGLE_CLIENT_SECRET:
|
||||
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
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);
|
||||
break;
|
||||
case InfraConfigEnumForClient.GITHUB_CLIENT_SECRET:
|
||||
case InfraConfigEnum.GITHUB_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnumForClient.MICROSOFT_CLIENT_ID:
|
||||
case InfraConfigEnum.GITHUB_CLIENT_SECRET:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
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);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from './helper';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
|
||||
@InputType()
|
||||
export class InfraConfigArgs {
|
||||
@Field(() => InfraConfigEnumForClient, {
|
||||
@Field(() => InfraConfigEnum, {
|
||||
description: 'Infra Config Name',
|
||||
})
|
||||
name: InfraConfigEnumForClient;
|
||||
name: InfraConfigEnum;
|
||||
|
||||
@Field({
|
||||
description: 'Infra Config Value',
|
||||
|
||||
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal file
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe
|
||||
import { TeamModule } from '../team/team.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
import { TeamCollectionController } from './team-collection.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
|
||||
@@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
GqlCollectionTeamMemberGuard,
|
||||
],
|
||||
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
|
||||
controllers: [TeamCollectionController],
|
||||
})
|
||||
export class TeamCollectionModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TeamCollection } from './team-collection.model';
|
||||
import {
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
TEAM_COL_SAME_NEXT_COLL,
|
||||
TEAM_COL_REORDERING_FAILED,
|
||||
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';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
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 { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
|
||||
@Injectable()
|
||||
export class TeamCollectionService {
|
||||
@@ -1056,4 +1063,266 @@ export class TeamCollectionService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -4,12 +4,19 @@ export enum InfraConfigEnum {
|
||||
|
||||
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
|
||||
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
|
||||
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
|
||||
GOOGLE_SCOPE = 'GOOGLE_SCOPE',
|
||||
|
||||
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
|
||||
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
|
||||
GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL',
|
||||
GITHUB_SCOPE = 'GITHUB_SCOPE',
|
||||
|
||||
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
|
||||
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',
|
||||
|
||||
@@ -17,20 +24,3 @@ export enum InfraConfigEnum {
|
||||
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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
|
||||
*/
|
||||
export type AuthError = {
|
||||
export type RESTError = {
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { ExecutionContext, HttpException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { pipe } from 'fp-ts/lib/function';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
JSON_INVALID,
|
||||
} from './errors';
|
||||
import { AuthProvider } from './auth/helper';
|
||||
import { RESTError } from './types/RESTError';
|
||||
|
||||
/**
|
||||
* A workaround to throw an exception in an expression.
|
||||
@@ -27,6 +28,15 @@ export function throwErr(errMessage: string): never {
|
||||
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.
|
||||
* Used for debugging functional pipelines.
|
||||
@@ -173,6 +183,16 @@ export const validateSMTPUrl = (url: string) => {
|
||||
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
|
||||
* @param {str} str The string to parse
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestD",
|
||||
"params": [],
|
||||
@@ -40,7 +40,8 @@
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
@@ -52,7 +53,7 @@
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestC",
|
||||
"params": [],
|
||||
@@ -67,7 +68,8 @@
|
||||
"body": {
|
||||
"contentType": null,
|
||||
"body": null
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
@@ -88,7 +90,7 @@
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestB",
|
||||
"params": [],
|
||||
@@ -104,6 +106,7 @@
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"requestVariables": [],
|
||||
"id": "clpttpdq00003qp16kut6doqv"
|
||||
}
|
||||
],
|
||||
@@ -116,7 +119,7 @@
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestA",
|
||||
"params": [],
|
||||
@@ -132,6 +135,7 @@
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"requestVariables": [],
|
||||
"id": "clpttpdq00003qp16kut6doqv"
|
||||
}
|
||||
],
|
||||
@@ -158,7 +162,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestB",
|
||||
"params": [],
|
||||
@@ -174,6 +178,7 @@
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"requestVariables": [],
|
||||
"id": "clpttpdq00003qp16kut6doqv"
|
||||
}
|
||||
],
|
||||
@@ -186,7 +191,7 @@
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"name": "RequestA",
|
||||
"params": [],
|
||||
@@ -202,6 +207,7 @@
|
||||
"contentType": null,
|
||||
"body": null
|
||||
},
|
||||
"requestVariables": [],
|
||||
"id": "clpttpdq00003qp16kut6doqv"
|
||||
}
|
||||
],
|
||||
@@ -218,4 +224,4 @@
|
||||
"token": "BearerToken"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "<<URL>>",
|
||||
"name": "test1",
|
||||
"params": [],
|
||||
@@ -16,7 +16,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
|
||||
"name": "",
|
||||
"params": [],
|
||||
@@ -23,10 +23,11 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": [],
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.dio/<<HEADERS_TYPE2>>",
|
||||
"name": "success",
|
||||
"params": [],
|
||||
@@ -44,7 +45,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{
|
||||
"v": 1,
|
||||
"folders": [],
|
||||
"requests":
|
||||
"requests":
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
|
||||
"name": "fail",
|
||||
"params": [],
|
||||
@@ -22,10 +22,11 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": [],
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
|
||||
"name": "success",
|
||||
"params": [],
|
||||
@@ -43,7 +44,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{
|
||||
"v": 1,
|
||||
"folders": [],
|
||||
"requests":
|
||||
"requests":
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
|
||||
"name": "fail",
|
||||
"params": [],
|
||||
@@ -22,7 +22,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE1>>",
|
||||
"name": "",
|
||||
"params": [],
|
||||
@@ -23,10 +23,11 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE1>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://echo.hoppscotch.io/<<HEADERS_TYPE2>>",
|
||||
"name": "success",
|
||||
"params": [],
|
||||
@@ -44,7 +45,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n\"test\": \"<<HEADERS_TYPE2>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": { "authType": "none", "authActive": true },
|
||||
"body": { "body": null, "contentType": null },
|
||||
"name": "sample-req",
|
||||
@@ -13,7 +13,8 @@
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")",
|
||||
"preRequestScript": "pw.env.set(\"variable\", \"value\");"
|
||||
"preRequestScript": "pw.env.set(\"variable\", \"value\");",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": { "authType": "inherit", "authActive": true },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"name": "test-request",
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"method": "POST",
|
||||
@@ -19,7 +19,8 @@
|
||||
"body": "{\n \"firstName\": \"<<firstName>>\",\n \"lastName\": \"<<lastName>>\",\n \"greetText\": \"<<salutation>>, <<fullName>>\",\n \"fullName\": \"<<fullName>>\",\n \"id\": \"<<id>>\"\n}"
|
||||
},
|
||||
"preRequestScript": "",
|
||||
"testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});"
|
||||
"testScript": "pw.test(\"Status code is 200\", ()=> {\n pw.expect(pw.response.status).toBe(200);\n});\n\npw.test(\"Successfully resolves environments recursively\", ()=> {\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n});\n\npw.test(\"Successfully resolves environments referenced in the request body\", () => {\n const expectedId = \"7\"\n const expectedFirstName = \"John\"\n const expectedLastName = \"Doe\"\n const expectedFullName = `${expectedFirstName} ${expectedLastName}`\n const expectedGreetText = `Hello, ${expectedFullName}`\n\n pw.expect(pw.env.getResolve(\"recursiveVarX\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"recursiveVarY\")).toBe(\"Hello\")\n pw.expect(pw.env.getResolve(\"salutation\")).toBe(\"Hello\")\n\n const { id, firstName, lastName, fullName, greetText } = JSON.parse(pw.response.body.data)\n\n pw.expect(id).toBe(expectedId)\n pw.expect(expectedFirstName).toBe(firstName)\n pw.expect(expectedLastName).toBe(lastName)\n pw.expect(fullName).toBe(expectedFullName)\n pw.expect(greetText).toBe(expectedGreetText)\n});",
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": { "authType": "none", "authActive": true },
|
||||
"body": { "body": null, "contentType": null },
|
||||
"name": "test-secret-headers",
|
||||
@@ -17,12 +17,13 @@
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"requestVariables": [],
|
||||
"endpoint": "<<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})",
|
||||
"preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": { "authType": "none", "authActive": true },
|
||||
"body": {
|
||||
"body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}",
|
||||
@@ -32,12 +33,13 @@
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"requestVariables": [],
|
||||
"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})",
|
||||
"preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": { "authType": "none", "authActive": true },
|
||||
"body": { "body": null, "contentType": null },
|
||||
"name": "test-secret-query-params",
|
||||
@@ -50,12 +52,13 @@
|
||||
}
|
||||
],
|
||||
"headers": [],
|
||||
"requestVariables": [],
|
||||
"endpoint": "<<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})",
|
||||
"preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "<<secretBasicAuthPassword>>",
|
||||
@@ -67,12 +70,13 @@
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"requestVariables": [],
|
||||
"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});",
|
||||
"preRequestScript": ""
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"token": "<<secretBearerToken>>",
|
||||
"authType": "bearer",
|
||||
@@ -85,18 +89,20 @@
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"requestVariables": [],
|
||||
"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});",
|
||||
"preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": { "authType": "none", "authActive": true },
|
||||
"body": { "body": null, "contentType": null },
|
||||
"name": "test-secret-fallback",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"requestVariables": [],
|
||||
"endpoint": "<<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})",
|
||||
"preRequestScript": ""
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
@@ -16,6 +16,7 @@
|
||||
"name": "test-secret-headers",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"requestVariables": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Secret-Header-Key",
|
||||
@@ -28,7 +29,7 @@
|
||||
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
@@ -40,6 +41,7 @@
|
||||
"name": "test-secret-headers-overrides",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"requestVariables": [],
|
||||
"headers": [
|
||||
{
|
||||
"key": "Secret-Header-Key",
|
||||
@@ -52,7 +54,7 @@
|
||||
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
@@ -64,13 +66,14 @@
|
||||
"name": "test-secret-body",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"requestVariables": [],
|
||||
"headers": [],
|
||||
"endpoint": "<<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})",
|
||||
"preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "none",
|
||||
"authActive": true
|
||||
@@ -88,13 +91,14 @@
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"requestVariables": [],
|
||||
"headers": [],
|
||||
"endpoint": "<<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})",
|
||||
"preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"authType": "basic",
|
||||
"password": "<<secretBasicAuthPassword>>",
|
||||
@@ -108,13 +112,14 @@
|
||||
"name": "test-secret-basic-auth",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"requestVariables": [],
|
||||
"headers": [],
|
||||
"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});",
|
||||
"preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}"
|
||||
},
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"auth": {
|
||||
"token": "<<secretBearerToken>>",
|
||||
"authType": "bearer",
|
||||
@@ -129,6 +134,7 @@
|
||||
"name": "test-secret-bearer-auth",
|
||||
"method": "GET",
|
||||
"params": [],
|
||||
"requestVariables": [],
|
||||
"headers": [],
|
||||
"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});",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "1",
|
||||
"v": "2",
|
||||
"endpoint": "https://httpbin.org/post",
|
||||
"name": "req",
|
||||
"params": [],
|
||||
@@ -22,7 +22,8 @@
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"body": "{\n \"key\": \"<<customBodyValue>>\"\n}"
|
||||
}
|
||||
},
|
||||
"requestVariables": []
|
||||
}
|
||||
],
|
||||
"auth": { "authType": "inherit", "authActive": false },
|
||||
|
||||
@@ -563,12 +563,22 @@ details[open] summary .indicator {
|
||||
.env-highlight {
|
||||
@apply text-accentContrast;
|
||||
|
||||
&.env-found {
|
||||
@apply bg-accentDark;
|
||||
@apply hover:bg-accent;
|
||||
&.request-variable-highlight {
|
||||
@apply bg-amber-500;
|
||||
@apply hover:bg-amber-600;
|
||||
}
|
||||
|
||||
&.env-not-found {
|
||||
&.environment-variable-highlight {
|
||||
@apply bg-green-500;
|
||||
@apply hover:bg-green-600;
|
||||
}
|
||||
|
||||
&.global-variable-highlight {
|
||||
@apply bg-blue-500;
|
||||
@apply hover:bg-blue-600;
|
||||
}
|
||||
|
||||
&.environment-not-found-highlight {
|
||||
@apply bg-red-500;
|
||||
@apply hover:bg-red-600;
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"pending_invites": "There are no pending invites for this team",
|
||||
"profile": "Login to view your profile",
|
||||
"protocols": "Protocols are empty",
|
||||
"request_variables": "This request does not have any request variables",
|
||||
"schema": "Connect to a GraphQL endpoint to view schema",
|
||||
"secret_environments": "Secrets are not synced to Hoppscotch",
|
||||
"shared_requests": "Shared requests are empty",
|
||||
@@ -315,7 +316,8 @@
|
||||
"proxy_error": "Proxy error",
|
||||
"script_fail": "Could not execute pre-request script",
|
||||
"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": {
|
||||
"as_json": "Export as JSON",
|
||||
@@ -413,7 +415,10 @@
|
||||
"json_description": "Import collections from a Hoppscotch Collections JSON file",
|
||||
"postman_environment": "Postman Environment",
|
||||
"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": {
|
||||
"description": "Inspect possible errors",
|
||||
@@ -556,6 +561,7 @@
|
||||
"raw_body": "Raw Request Body",
|
||||
"rename": "Rename Request",
|
||||
"renamed": "Request renamed",
|
||||
"request_variables": "Request variables",
|
||||
"run": "Run",
|
||||
"save": "Save",
|
||||
"save_as": "Save as",
|
||||
|
||||
@@ -263,7 +263,7 @@ const HoppOpenAPIImporter: ImporterOrExporter = {
|
||||
step: UrlSource({
|
||||
caption: "import.from_url",
|
||||
onImportFromURL: async (content) => {
|
||||
const res = await hoppOpenAPIImporter(content)()
|
||||
const res = await hoppOpenAPIImporter([content])()
|
||||
|
||||
if (E.isRight(res)) {
|
||||
handleImportToStore(res.right)
|
||||
|
||||
@@ -694,7 +694,7 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
|
||||
let target = collections[indexPaths.shift() as number]
|
||||
|
||||
while (indexPaths.length > 0)
|
||||
target = target.folders[indexPaths.shift() as number]
|
||||
target = target?.folders[indexPaths.shift() as number]
|
||||
|
||||
return target !== undefined ? target : null
|
||||
}
|
||||
|
||||
@@ -32,9 +32,13 @@
|
||||
{{ tab.document.request.method }}
|
||||
</span>
|
||||
<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 class="flex mt-2 space-x-2 sm:mt-0">
|
||||
@@ -69,6 +73,7 @@
|
||||
v-model="tab.document.request"
|
||||
v-model:option-tab="selectedOptionTab"
|
||||
:properties="properties"
|
||||
:envs="tabRequestVariables"
|
||||
/>
|
||||
<HttpResponse
|
||||
v-if="tab.document.response"
|
||||
@@ -117,6 +122,15 @@ const sharedRequestURL = computed(() => {
|
||||
return `${shortcodeBaseURL}/r/${props.sharedRequestID}`
|
||||
})
|
||||
|
||||
const tabRequestVariables = computed(() => {
|
||||
return tab.value.document.request.requestVariables.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
secret: false,
|
||||
sourceEnv: "RequestVariable",
|
||||
}))
|
||||
})
|
||||
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const newSendRequest = async () => {
|
||||
|
||||
@@ -133,7 +133,7 @@ const PostmanEnvironmentsImport: ImporterOrExporter = {
|
||||
return
|
||||
}
|
||||
|
||||
handleImportToStore([res.right])
|
||||
handleImportToStore(res.right)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_IMPORT_ENVIRONMENT",
|
||||
@@ -166,19 +166,14 @@ const insomniaEnvironmentsImport: ImporterOrExporter = {
|
||||
return
|
||||
}
|
||||
|
||||
const globalEnvIndex = res.right.findIndex(
|
||||
const globalEnvs = res.right.filter(
|
||||
(env) => env.name === "Base Environment"
|
||||
)
|
||||
const otherEnvs = res.right.filter(
|
||||
(env) => env.name !== "Base Environment"
|
||||
)
|
||||
|
||||
const globalEnv =
|
||||
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)
|
||||
handleImportToStore(otherEnvs, globalEnvs)
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_IMPORT_ENVIRONMENT",
|
||||
@@ -340,14 +335,14 @@ const showImportFailedError = () => {
|
||||
|
||||
const handleImportToStore = async (
|
||||
environments: Environment[],
|
||||
globalEnv?: NonSecretEnvironment
|
||||
globalEnvs: NonSecretEnvironment[] = []
|
||||
) => {
|
||||
// if there's a global env, add them to the store
|
||||
if (globalEnv) {
|
||||
globalEnv.variables.forEach(({ key, value, secret }) =>
|
||||
// Add global envs to the store
|
||||
globalEnvs.forEach(({ variables }) => {
|
||||
variables.forEach(({ key, value, secret }) => {
|
||||
addGlobalEnvVariable({ key, value, secret })
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (props.environmentType === "MY_ENV") {
|
||||
appendEnvironments(environments)
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<div v-else class="flex flex-1 border-b border-dividerLight">
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div v-if="auth.authType === 'basic'">
|
||||
<HttpAuthorizationBasic v-model="auth" />
|
||||
<HttpAuthorizationBasic v-model="auth" :envs="envs" />
|
||||
</div>
|
||||
<div v-if="auth.authType === 'inherit'" class="p-4">
|
||||
<span v-if="inheritedProperties?.auth">
|
||||
@@ -169,17 +169,26 @@
|
||||
</div>
|
||||
<div v-if="auth.authType === 'bearer'">
|
||||
<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 v-if="auth.authType === 'oauth-2'">
|
||||
<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>
|
||||
<HttpOAuth2Authorization v-model="auth" />
|
||||
<HttpOAuth2Authorization v-model="auth" :envs="envs" />
|
||||
</div>
|
||||
<div v-if="auth.authType === 'api-key'">
|
||||
<HttpAuthorizationApiKey v-model="auth" />
|
||||
<HttpAuthorizationApiKey v-model="auth" :envs="envs" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -215,6 +224,7 @@ import { useColorMode } from "@composables/theming"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { onMounted } from "vue"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -225,6 +235,7 @@ const props = defineProps<{
|
||||
isCollectionProperty?: boolean
|
||||
isRootCollection?: boolean
|
||||
inheritedProperties?: HoppInheritedProperty
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -100,10 +100,12 @@
|
||||
<HttpBodyParameters
|
||||
v-if="body.contentType === 'multipart/form-data'"
|
||||
v-model="body"
|
||||
:envs="envs"
|
||||
/>
|
||||
<HttpURLEncodedParams
|
||||
v-else-if="body.contentType === 'application/x-www-form-urlencoded'"
|
||||
v-model="body"
|
||||
:envs="envs"
|
||||
/>
|
||||
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
|
||||
<HoppSmartPlaceholder
|
||||
@@ -141,6 +143,7 @@ import IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconInfo from "~icons/lucide/info"
|
||||
import IconRefreshCW from "~icons/lucide/refresh-cw"
|
||||
import { RESTOptionTabs } from "./RequestOptions.vue"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
@@ -148,6 +151,7 @@ const t = useI18n()
|
||||
const props = defineProps<{
|
||||
body: HoppRESTReqBody
|
||||
headers: HoppRESTHeader[]
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
<SmartEnvInput
|
||||
v-model="entry.key"
|
||||
:placeholder="`${t('count.parameter', { count: index + 1 })}`"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateBodyParam(index, {
|
||||
key: $event,
|
||||
@@ -87,6 +89,8 @@
|
||||
<SmartEnvInput
|
||||
v-model="entry.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateBodyParam(index, {
|
||||
key: entry.key,
|
||||
@@ -190,11 +194,13 @@ import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
type Body = HoppRESTReqBody & { contentType: "multipart/form-data" }
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Body
|
||||
envs: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -188,11 +188,25 @@ const copyCodeIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
|
||||
const requestCode = computed(() => {
|
||||
const aggregateEnvs = getAggregateEnvs()
|
||||
const requestVariables = request.value.requestVariables.map(
|
||||
(requestVariable) => {
|
||||
if (requestVariable.active)
|
||||
return {
|
||||
key: requestVariable.key,
|
||||
value: requestVariable.value,
|
||||
secret: false,
|
||||
}
|
||||
return {}
|
||||
}
|
||||
)
|
||||
const env: Environment = {
|
||||
v: 1,
|
||||
id: "env",
|
||||
name: "Env",
|
||||
variables: aggregateEnvs,
|
||||
variables: [
|
||||
...(requestVariables as Environment["variables"]),
|
||||
...aggregateEnvs,
|
||||
],
|
||||
}
|
||||
const effectiveRequest = getEffectiveRESTRequest(request.value, env)
|
||||
|
||||
@@ -212,6 +226,12 @@ const requestCode = computed(() => {
|
||||
active: true,
|
||||
})),
|
||||
endpoint: effectiveRequest.effectiveFinalURL,
|
||||
requestVariables: effectiveRequest.effectiveFinalRequestVariables.map(
|
||||
(requestVariable) => ({
|
||||
...requestVariable,
|
||||
active: true,
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="bulkMode"
|
||||
v-if="bulkHeaders"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': WRAP_LINES }"
|
||||
@@ -92,6 +92,8 @@
|
||||
:auto-complete-source="commonHeaders"
|
||||
:env-index="index"
|
||||
:inspection-results="getInspectorResult(headerKeyResults, index)"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
@@ -108,6 +110,8 @@
|
||||
getInspectorResult(headerValueResults, index)
|
||||
"
|
||||
:env-index="index"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateHeader(index, {
|
||||
id: header.id,
|
||||
@@ -329,7 +333,11 @@ import {
|
||||
getComputedHeaders,
|
||||
getComputedAuthHeaders,
|
||||
} from "~/helpers/utils/EffectiveURL"
|
||||
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
|
||||
import {
|
||||
AggregateEnvironment,
|
||||
aggregateEnvs$,
|
||||
getAggregateEnvs,
|
||||
} from "~/newstore/environments"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { InspectionService, InspectorResult } from "~/services/inspection"
|
||||
@@ -359,6 +367,7 @@ const props = defineProps<{
|
||||
modelValue: HoppRESTRequest
|
||||
isCollectionProperty?: boolean
|
||||
inheritedProperties?: HoppInheritedProperty
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -6,31 +6,52 @@
|
||||
:styles="
|
||||
hasAccessTokenOrAuthURL ? 'pointer-events-none opacity-70' : ''
|
||||
"
|
||||
:auto-complete-env="true"
|
||||
placeholder="OpenID Connect Discovery URL"
|
||||
:envs="envs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="authURL"
|
||||
placeholder="Authorization URL"
|
||||
:auto-complete-env="true"
|
||||
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
|
||||
></SmartEnvInput>
|
||||
:envs="envs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="accessTokenURL"
|
||||
placeholder="Access Token URL"
|
||||
:auto-complete-env="true"
|
||||
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
|
||||
:envs="envs"
|
||||
/>
|
||||
</div>
|
||||
<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 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 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 class="p-2">
|
||||
<HoppButtonSecondary
|
||||
@@ -62,6 +83,7 @@ const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
:inspection-results="
|
||||
getInspectorResult(parameterKeyResults, index)
|
||||
"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateParam(index, {
|
||||
id: param.id,
|
||||
@@ -102,6 +104,8 @@
|
||||
:inspection-results="
|
||||
getInspectorResult(parameterValueResults, index)
|
||||
"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateParam(index, {
|
||||
id: param.id,
|
||||
@@ -209,6 +213,7 @@ import { InspectionService, InspectorResult } from "~/services/inspection"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
@@ -242,6 +247,7 @@ useCodemirror(
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppRESTParam[]
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
v-model="tab.document.request.endpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:auto-complete-source="userHistories"
|
||||
:auto-complete-env="true"
|
||||
:inspection-results="tabResults"
|
||||
@paste="onPasteUrl($event)"
|
||||
@enter="newSendRequest"
|
||||
|
||||
@@ -5,48 +5,51 @@
|
||||
render-inactive-tabs
|
||||
>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('params') : true"
|
||||
v-if="properties?.includes('params') ?? true"
|
||||
:id="'params'"
|
||||
:label="`${t('tab.parameters')}`"
|
||||
:info="`${newActiveParamsCount$}`"
|
||||
:info="`${newActiveParamsCount}`"
|
||||
>
|
||||
<HttpParameters v-model="request.params" />
|
||||
<HttpParameters v-model="request.params" :envs="envs" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('bodyParams') : true"
|
||||
v-if="properties?.includes('bodyParams') ?? true"
|
||||
:id="'bodyParams'"
|
||||
:label="`${t('tab.body')}`"
|
||||
>
|
||||
<HttpBody
|
||||
v-model:headers="request.headers"
|
||||
v-model:body="request.body"
|
||||
:envs="envs"
|
||||
@change-tab="changeOptionTab"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('headers') : true"
|
||||
v-if="properties?.includes('headers') ?? true"
|
||||
:id="'headers'"
|
||||
:label="`${t('tab.headers')}`"
|
||||
:info="`${newActiveHeadersCount$}`"
|
||||
:info="`${newActiveHeadersCount}`"
|
||||
>
|
||||
<HttpHeaders
|
||||
v-model="request"
|
||||
:inherited-properties="inheritedProperties"
|
||||
:envs="envs"
|
||||
@change-tab="changeOptionTab"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('authorization') : true"
|
||||
v-if="properties?.includes('authorization') ?? true"
|
||||
:id="'authorization'"
|
||||
:label="`${t('tab.authorization')}`"
|
||||
>
|
||||
<HttpAuthorization
|
||||
v-model="request.auth"
|
||||
:inherited-properties="inheritedProperties"
|
||||
:envs="envs"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('preRequestScript') : true"
|
||||
v-if="properties?.includes('preRequestScript') ?? true"
|
||||
:id="'preRequestScript'"
|
||||
:label="`${t('tab.pre_request_script')}`"
|
||||
:indicator="
|
||||
@@ -58,7 +61,7 @@
|
||||
<HttpPreRequestScript v-model="request.preRequestScript" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
v-if="properties ? properties.includes('tests') : true"
|
||||
v-if="properties?.includes('tests') ?? true"
|
||||
:id="'tests'"
|
||||
:label="`${t('tab.tests')}`"
|
||||
:indicator="
|
||||
@@ -67,6 +70,15 @@
|
||||
>
|
||||
<HttpTests v-model="request.testScript" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -77,6 +89,7 @@ import { useVModel } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const VALID_OPTION_TABS = [
|
||||
"params",
|
||||
@@ -85,6 +98,7 @@ const VALID_OPTION_TABS = [
|
||||
"authorization",
|
||||
"preRequestScript",
|
||||
"tests",
|
||||
"requestVariables",
|
||||
] as const
|
||||
|
||||
export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number]
|
||||
@@ -98,6 +112,7 @@ const props = withDefaults(
|
||||
optionTab: RESTOptionTabs
|
||||
properties?: string[]
|
||||
inheritedProperties?: HoppInheritedProperty
|
||||
envs?: AggregateEnvironment[]
|
||||
}>(),
|
||||
{
|
||||
optionTab: "params",
|
||||
@@ -116,22 +131,27 @@ const changeOptionTab = (e: RESTOptionTabs) => {
|
||||
selectedOptionTab.value = e
|
||||
}
|
||||
|
||||
const newActiveParamsCount$ = computed(() => {
|
||||
const e = request.value.params.filter(
|
||||
(x) => x.active && (x.key !== "" || x.value !== "")
|
||||
const newActiveParamsCount = computed(() => {
|
||||
const count = request.value.params.filter(
|
||||
(x) => x.active && (x.key || x.value)
|
||||
).length
|
||||
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
return count ? count : null
|
||||
})
|
||||
|
||||
const newActiveHeadersCount$ = computed(() => {
|
||||
const e = request.value.headers.filter(
|
||||
(x) => x.active && (x.key !== "" || x.value !== "")
|
||||
const newActiveHeadersCount = computed(() => {
|
||||
const count = request.value.headers.filter(
|
||||
(x) => x.active && (x.key || x.value)
|
||||
).length
|
||||
|
||||
if (e === 0) return null
|
||||
return `${e}`
|
||||
return count ? count : null
|
||||
})
|
||||
|
||||
const newActiveRequestVariablesCount = computed(() => {
|
||||
const count = request.value.requestVariables.filter(
|
||||
(x) => x.active && (x.key || x.value)
|
||||
).length
|
||||
return count ? count : null
|
||||
})
|
||||
|
||||
defineActionHandler("request.open-tab", ({ tab }) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -21,7 +21,7 @@
|
||||
@click="clearContent()"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="bulkMode"
|
||||
v-if="bulkUrlEncodedParams"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('state.linewrap')"
|
||||
:class="{ '!text-accent': WRAP_LINES }"
|
||||
@@ -84,6 +84,8 @@
|
||||
<SmartEnvInput
|
||||
v-model="param.key"
|
||||
:placeholder="`${t('count.parameter', { count: index + 1 })}`"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateUrlEncodedParam(index, {
|
||||
id: param.id,
|
||||
@@ -96,6 +98,8 @@
|
||||
<SmartEnvInput
|
||||
v-model="param.value"
|
||||
:placeholder="`${t('count.value', { count: index + 1 })}`"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
@change="
|
||||
updateUrlEncodedParam(index, {
|
||||
id: param.id,
|
||||
@@ -200,6 +204,7 @@ import { throwError } from "~/helpers/functional/error"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { useNestedSetting } from "~/composables/settings"
|
||||
import { toggleNestedSetting } from "~/newstore/settings"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
type Body = HoppRESTReqBody & {
|
||||
contentType: "application/x-www-form-urlencoded"
|
||||
@@ -207,6 +212,7 @@ type Body = HoppRESTReqBody & {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Body
|
||||
envs: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<template>
|
||||
<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 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 class="flex items-center border-b border-dividerLight">
|
||||
<span class="flex items-center">
|
||||
@@ -65,11 +75,13 @@ import { useI18n } from "@composables/i18n"
|
||||
import { HoppRESTAuthAPIKey } from "@hoppscotch/data"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { ref } from "vue"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppRESTAuthAPIKey
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
<SmartEnvInput
|
||||
v-model="auth.username"
|
||||
:placeholder="t('authorization.username')"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput
|
||||
v-model="auth.password"
|
||||
:placeholder="t('authorization.password')"
|
||||
:auto-complete-env="true"
|
||||
:envs="envs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,11 +21,13 @@
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { HoppRESTAuthBasic } from "@hoppscotch/data"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { AggregateEnvironment } from "~/newstore/environments"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HoppRESTAuthBasic
|
||||
envs?: AggregateEnvironment[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{{ t(`${caption}`) }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark"
|
||||
>
|
||||
@@ -23,15 +24,30 @@
|
||||
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"
|
||||
:accept="acceptedFileTypes"
|
||||
multiple
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</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>
|
||||
<HoppButtonPrimary
|
||||
class="w-full"
|
||||
:label="t('import.title')"
|
||||
:disabled="!hasFile"
|
||||
:disabled="!hasFile || showFileSizeLimitExceededWarning"
|
||||
@click="emit('importFromFile', fileContent)"
|
||||
/>
|
||||
</div>
|
||||
@@ -51,16 +67,30 @@ defineProps<{
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const ALLOWED_FILE_SIZE_LIMIT = 10 // 10 MB
|
||||
|
||||
const importFilesCount = ref(0)
|
||||
|
||||
const hasFile = ref(false)
|
||||
const fileContent = ref("")
|
||||
const showFileSizeLimitExceededWarning = ref(false)
|
||||
const fileContent = ref<string[]>([])
|
||||
|
||||
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
|
||||
|
||||
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
|
||||
|
||||
if (!inputFileToImport) {
|
||||
@@ -69,27 +99,52 @@ const onFileChange = () => {
|
||||
}
|
||||
|
||||
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
|
||||
inputChooseFileToImportFrom.value[0].value = ""
|
||||
inputChooseFileToImportFrom.value = ""
|
||||
hasFile.value = false
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
const readerPromises: Promise<string | null>[] = []
|
||||
|
||||
reader.onload = ({ target }) => {
|
||||
const content = target!.result as string | null
|
||||
if (!content) {
|
||||
hasFile.value = false
|
||||
toast.show(t("action.choose_file").toString())
|
||||
return
|
||||
let totalFileSize = 0
|
||||
|
||||
for (let i = 0; i < inputFileToImport.files.length; i++) {
|
||||
const file = inputFileToImport.files[i]
|
||||
|
||||
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>
|
||||
|
||||
@@ -21,28 +21,24 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2 py-4">
|
||||
<span>
|
||||
<HoppSmartItem
|
||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||
blank
|
||||
:icon="IconChrome"
|
||||
label="Chrome"
|
||||
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasChromeExtInstalled"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<HoppSmartItem
|
||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||
blank
|
||||
:icon="IconFirefox"
|
||||
label="Firefox"
|
||||
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasFirefoxExtInstalled"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
<HoppSmartItem
|
||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||
blank
|
||||
:icon="IconChrome"
|
||||
label="Chrome"
|
||||
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasChromeExtInstalled"
|
||||
outline
|
||||
/>
|
||||
<HoppSmartItem
|
||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||
blank
|
||||
:icon="IconFirefox"
|
||||
label="Firefox"
|
||||
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasFirefoxExtInstalled"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -290,7 +290,12 @@ const widgets: Widget[] = [
|
||||
},
|
||||
]
|
||||
|
||||
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization"
|
||||
type EmbedTabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
| "requestVariables"
|
||||
|
||||
type EmbedOption = {
|
||||
selectedTab: EmbedTabs
|
||||
|
||||
@@ -56,7 +56,12 @@ import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization"
|
||||
type EmbedTabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
| "requestVariables"
|
||||
|
||||
type EmbedOption = {
|
||||
selectedTab: EmbedTabs
|
||||
|
||||
@@ -173,6 +173,11 @@ const embedOptions = ref<EmbedOption>({
|
||||
label: t("tab.authorization"),
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
value: "requestVariables",
|
||||
label: t("tab.variables"),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
theme: "system",
|
||||
})
|
||||
@@ -223,7 +228,12 @@ const currentUser = useReadonlyStream(
|
||||
|
||||
const step = ref(1)
|
||||
|
||||
type EmbedTabs = "params" | "bodyParams" | "headers" | "authorization"
|
||||
type EmbedTabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
| "requestVariables"
|
||||
|
||||
type EmbedOption = {
|
||||
selectedTab: EmbedTabs
|
||||
@@ -369,6 +379,11 @@ const displayCustomizeRequestModal = (
|
||||
label: t("tab.authorization"),
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
value: "requestVariables",
|
||||
label: t("tab.variables"),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
theme: "system",
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex"
|
||||
class="flex overflow-x-scroll"
|
||||
:class="[{ 'border-b pt-2 ': !noActiveTab }, embedColours]"
|
||||
>
|
||||
<span
|
||||
@@ -57,7 +57,12 @@ import { computed } from "vue"
|
||||
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
type Tabs = "params" | "bodyParams" | "headers" | "authorization"
|
||||
type Tabs =
|
||||
| "params"
|
||||
| "bodyParams"
|
||||
| "headers"
|
||||
| "authorization"
|
||||
| "requestVariables"
|
||||
|
||||
type EmbedOption = {
|
||||
selectedTab: Tabs
|
||||
|
||||
@@ -79,29 +79,27 @@ import { history, historyKeymap } from "@codemirror/commands"
|
||||
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
|
||||
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import {
|
||||
AggregateEnvironment,
|
||||
aggregateEnvsWithSecrets$,
|
||||
} from "~/newstore/environments"
|
||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||
import { platform } from "~/platform"
|
||||
import { onClickOutside, useDebounceFn } from "@vueuse/core"
|
||||
import { InspectorResult } from "~/services/inspection"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
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()
|
||||
|
||||
type Env = Environment["variables"][number] & { source: string }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
styles?: string
|
||||
envs?: Env[] | null
|
||||
envs?: AggregateEnvironment[] | null
|
||||
focus?: boolean
|
||||
selectTextOnMount?: boolean
|
||||
environmentHighlights?: boolean
|
||||
@@ -110,6 +108,7 @@ const props = withDefaults(
|
||||
inspectionResults?: InspectorResult[] | undefined
|
||||
contextMenuEnabled?: boolean
|
||||
secret?: boolean
|
||||
autoCompleteEnv?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
@@ -124,6 +123,7 @@ const props = withDefaults(
|
||||
inspectionResults: undefined,
|
||||
contextMenuEnabled: true,
|
||||
secret: false,
|
||||
autoCompleteEnvSource: false,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -353,29 +353,62 @@ watch(
|
||||
let clipboardEv: ClipboardEvent | null = null
|
||||
let pastedValue: string | null = null
|
||||
|
||||
const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref<
|
||||
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
|
||||
AggregateEnvironment[]
|
||||
>
|
||||
|
||||
const tabs = useService(RESTTabService)
|
||||
|
||||
const envVars = computed(() => {
|
||||
return props.envs
|
||||
? props.envs.map((x) => {
|
||||
if (x.secret) {
|
||||
return {
|
||||
key: x.key,
|
||||
sourceEnv: "source" in x ? x.source : null,
|
||||
value: "********",
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: x.key,
|
||||
value: x.value,
|
||||
sourceEnv: "source" in x ? x.source : null,
|
||||
}
|
||||
})
|
||||
: aggregateEnvs.value
|
||||
if (props.envs) {
|
||||
return props.envs.map((x) => {
|
||||
const { key, secret } = x
|
||||
const value = secret ? "********" : x.value
|
||||
const sourceEnv = "sourceEnv" in x ? x.sourceEnv : null
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
sourceEnv,
|
||||
secret,
|
||||
}
|
||||
})
|
||||
}
|
||||
return [
|
||||
...tabs.currentActiveTab.value.document.request.requestVariables.map(
|
||||
({ active, key, value }) =>
|
||||
active
|
||||
? {
|
||||
key,
|
||||
value,
|
||||
sourceEnv: "RequestVariable",
|
||||
secret: false,
|
||||
}
|
||||
: ({} as AggregateEnvironment)
|
||||
),
|
||||
...aggregateEnvs.value,
|
||||
]
|
||||
})
|
||||
|
||||
function envAutoCompletion(context: CompletionContext) {
|
||||
const options = (envVars.value ?? [])
|
||||
.map((env) => ({
|
||||
label: env?.key ? `<<${env.key}>>` : "",
|
||||
info: env?.value ?? "",
|
||||
apply: env?.key ? `<<${env.key}>>` : "",
|
||||
}))
|
||||
.filter((x) => x)
|
||||
|
||||
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1)
|
||||
const textBefore = context.state.sliceDoc(nodeBefore.from, context.pos)
|
||||
const tagBefore = /<<\w*$/.exec(textBefore)
|
||||
if (!tagBefore && !context.explicit) return null
|
||||
return {
|
||||
from: tagBefore ? nodeBefore.from + tagBefore.index : context.pos,
|
||||
options: options,
|
||||
validFor: /^(<<\w*)?$/,
|
||||
}
|
||||
}
|
||||
|
||||
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||
|
||||
function handleTextSelection() {
|
||||
@@ -469,6 +502,12 @@ const getExtensions = (readonly: boolean): Extension => {
|
||||
}
|
||||
},
|
||||
}),
|
||||
props.autoCompleteEnv
|
||||
? autocompletion({
|
||||
activateOnTyping: true,
|
||||
override: [envAutoCompletion],
|
||||
})
|
||||
: [],
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
|
||||
@@ -243,7 +243,7 @@ export function useCodemirror(
|
||||
if (from === to) return
|
||||
const text = view.value?.state.doc.sliceString(from, to)
|
||||
const { top, left } = view.value?.coordsAtPos(from)
|
||||
if (text) {
|
||||
if (text?.trim()) {
|
||||
invokeAction("contextmenu.open", {
|
||||
position: {
|
||||
top,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import {
|
||||
Environment,
|
||||
HoppRESTHeaders,
|
||||
HoppRESTRequestVariable,
|
||||
} from "@hoppscotch/data"
|
||||
import { SandboxTestResult, TestDescriptor } from "@hoppscotch/js-sandbox"
|
||||
import { runTestScript } from "@hoppscotch/js-sandbox/web"
|
||||
import * as A from "fp-ts/Array"
|
||||
@@ -65,10 +69,17 @@ const getTestableBody = (
|
||||
return x
|
||||
}
|
||||
|
||||
const combineEnvVariables = (envs: {
|
||||
global: Environment["variables"]
|
||||
selected: Environment["variables"]
|
||||
}) => [...envs.selected, ...envs.global]
|
||||
const combineEnvVariables = (variables: {
|
||||
environments: {
|
||||
selected: Environment["variables"]
|
||||
global: Environment["variables"]
|
||||
}
|
||||
requestVariables: Environment["variables"]
|
||||
}) => [
|
||||
...variables.requestVariables,
|
||||
...variables.environments.selected,
|
||||
...variables.environments.global,
|
||||
]
|
||||
|
||||
export const executedResponses$ = new Subject<
|
||||
HoppRESTResponse & { type: "success" | "fail " }
|
||||
@@ -122,6 +133,38 @@ const updateEnvironmentsWithSecret = (
|
||||
return updatedEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the environment list to a list with unique keys with value
|
||||
* @param envs The environment list to be transformed
|
||||
* @returns The transformed environment list with keys with value
|
||||
*/
|
||||
const filterNonEmptyEnvironmentVariables = (
|
||||
envs: Environment["variables"]
|
||||
): Environment["variables"] => {
|
||||
const envsMap = new Map<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$(
|
||||
tab: Ref<HoppTab<HoppRESTDocument>>
|
||||
): [
|
||||
@@ -175,15 +218,40 @@ export function runRESTRequest$(
|
||||
requestHeaders = [...tab.value.document.request.headers]
|
||||
}
|
||||
|
||||
const finalRequestVariables =
|
||||
tab.value.document.request.requestVariables.map(
|
||||
(v: HoppRESTRequestVariable) => {
|
||||
if (v.active) {
|
||||
return {
|
||||
key: v.key,
|
||||
value: v.value,
|
||||
secret: false,
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
const finalRequest = {
|
||||
...tab.value.document.request,
|
||||
auth: requestAuth ?? { authType: "none", authActive: false },
|
||||
headers: requestHeaders,
|
||||
headers: requestHeaders as HoppRESTHeaders,
|
||||
}
|
||||
|
||||
const finalEnvs = {
|
||||
requestVariables: finalRequestVariables as Environment["variables"],
|
||||
environments: envs.right,
|
||||
}
|
||||
|
||||
const finalEnvsWithNonEmptyValues = filterNonEmptyEnvironmentVariables(
|
||||
combineEnvVariables(finalEnvs)
|
||||
)
|
||||
|
||||
const effectiveRequest = getEffectiveRESTRequest(finalRequest, {
|
||||
id: "env-id",
|
||||
v: 1,
|
||||
name: "Env",
|
||||
variables: combineEnvVariables(envs.right),
|
||||
variables: finalEnvsWithNonEmptyValues,
|
||||
})
|
||||
|
||||
const [stream, cancelRun] = createRESTNetworkRequestStream(effectiveRequest)
|
||||
|
||||
@@ -38,6 +38,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -141,6 +142,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -158,6 +160,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -181,6 +184,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -209,6 +213,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -239,6 +244,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -268,6 +274,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -291,6 +298,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -312,6 +320,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -335,6 +344,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -366,6 +376,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -430,6 +441,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -458,6 +470,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -515,6 +528,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -560,6 +574,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -607,6 +622,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -621,6 +637,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -645,6 +662,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -662,6 +680,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -685,6 +704,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -702,6 +722,7 @@ const samples = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -725,6 +746,7 @@ const samples = [
|
||||
],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -747,6 +769,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -767,6 +790,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -788,6 +812,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -808,6 +833,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -839,6 +865,7 @@ const samples = [
|
||||
headers: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
requestVariables: [],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -181,5 +181,6 @@ export const parseCurlCommand = (curlCommand: string) => {
|
||||
testScript: defaultRESTReq.testScript,
|
||||
auth,
|
||||
body: finalBody,
|
||||
requestVariables: defaultRESTReq.requestVariables,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { StreamSubscriberFunc } from "@composables/stream"
|
||||
import {
|
||||
AggregateEnvironment,
|
||||
aggregateEnvsWithSecrets$,
|
||||
getAggregateEnvsWithSecrets,
|
||||
getAggregateEnvs,
|
||||
getCurrentEnvironment,
|
||||
getSelectedEnvironmentType,
|
||||
} from "~/newstore/environments"
|
||||
@@ -23,15 +23,45 @@ import IconUsers from "~icons/lucide/users?raw"
|
||||
import IconEdit from "~icons/lucide/edit?raw"
|
||||
import { SecretEnvironmentService } from "~/services/secret-environment.service"
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
|
||||
|
||||
const HOPP_ENV_HIGHLIGHT =
|
||||
"cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight"
|
||||
const HOPP_ENV_HIGHLIGHT_FOUND = "env-found"
|
||||
const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "env-not-found"
|
||||
|
||||
const HOPP_REQUEST_VARIABLE_HIGHLIGHT = "request-variable-highlight"
|
||||
const HOPP_ENVIRONMENT_HIGHLIGHT = "environment-variable-highlight"
|
||||
const HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT = "global-variable-highlight"
|
||||
const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "environment-not-found-highlight"
|
||||
|
||||
const secretEnvironmentService = getService(SecretEnvironmentService)
|
||||
const restTabs = getService(RESTTabService)
|
||||
|
||||
/**
|
||||
* Transforms the environment list to a list with unique keys with value
|
||||
* @param envs The environment list to be transformed
|
||||
* @returns The transformed environment list with keys with value
|
||||
*/
|
||||
const filterNonEmptyEnvironmentVariables = (
|
||||
envs: AggregateEnvironment[]
|
||||
): AggregateEnvironment[] => {
|
||||
const envsMap = new Map<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[]) =>
|
||||
hoverTooltip(
|
||||
@@ -67,7 +97,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
|
||||
|
||||
const parsedEnvKey = text.slice(start - from, end - from)
|
||||
|
||||
const tooltipEnv = aggregateEnvs.find((env) => env.key === parsedEnvKey)
|
||||
const envsWithNoEmptyValues =
|
||||
filterNonEmptyEnvironmentVariables(aggregateEnvs)
|
||||
|
||||
const tooltipEnv = envsWithNoEmptyValues.find(
|
||||
(env) => env.key === parsedEnvKey
|
||||
)
|
||||
|
||||
const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment"
|
||||
|
||||
@@ -95,7 +130,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
|
||||
|
||||
const result = parseTemplateStringE(envValue, aggregateEnvs)
|
||||
|
||||
const finalEnv = E.isLeft(result) ? "error" : result.right
|
||||
let finalEnv = E.isLeft(result) ? "error" : result.right
|
||||
|
||||
// If the request variable has an secret variable
|
||||
// parseTemplateStringE is passed the secret value which has value undefined
|
||||
// So, we need to check if the result is undefined and then set the finalEnv to ******
|
||||
if (finalEnv === "undefined") finalEnv = "******"
|
||||
|
||||
const selectedEnvType = getSelectedEnvironmentType()
|
||||
|
||||
@@ -123,11 +163,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
|
||||
invokeActionType = "modals.my.environment.edit"
|
||||
}
|
||||
|
||||
invokeAction(invokeActionType, {
|
||||
envName: tooltipEnv?.sourceEnv !== "Global" ? envName : "Global",
|
||||
variableName: parsedEnvKey,
|
||||
isSecret: tooltipEnv?.secret,
|
||||
})
|
||||
if (tooltipEnv?.sourceEnv === "RequestVariable") {
|
||||
restTabs.currentActiveTab.value.document.optionTabPreference =
|
||||
"requestVariables"
|
||||
} else {
|
||||
invokeAction(invokeActionType, {
|
||||
envName: tooltipEnv?.sourceEnv === "Global" ? "Global" : envName,
|
||||
variableName: parsedEnvKey,
|
||||
isSecret: tooltipEnv?.secret,
|
||||
})
|
||||
}
|
||||
})
|
||||
editIcon.innerHTML = `<span class="inline-flex items-center justify-center my-1">${IconEdit}</span>`
|
||||
tooltip.appendChild(editIcon)
|
||||
@@ -165,11 +210,16 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
|
||||
)
|
||||
|
||||
function checkEnv(env: string, aggregateEnvs: AggregateEnvironment[]) {
|
||||
const className = aggregateEnvs.find(
|
||||
let className = HOPP_ENV_HIGHLIGHT_NOT_FOUND
|
||||
|
||||
const envSource = aggregateEnvs.find(
|
||||
(k: { key: string }) => k.key === env.slice(2, -2)
|
||||
)
|
||||
? HOPP_ENV_HIGHLIGHT_FOUND
|
||||
: HOPP_ENV_HIGHLIGHT_NOT_FOUND
|
||||
)?.sourceEnv
|
||||
|
||||
if (envSource === "RequestVariable")
|
||||
className = HOPP_REQUEST_VARIABLE_HIGHLIGHT
|
||||
else if (envSource === "Global") className = HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT
|
||||
else if (envSource !== undefined) className = HOPP_ENVIRONMENT_HIGHLIGHT
|
||||
|
||||
return Decoration.mark({
|
||||
class: `${HOPP_ENV_HIGHLIGHT} ${className}`,
|
||||
@@ -185,7 +235,10 @@ const getMatchDecorator = (aggregateEnvs: AggregateEnvironment[]) =>
|
||||
export const environmentHighlightStyle = (
|
||||
aggregateEnvs: AggregateEnvironment[]
|
||||
) => {
|
||||
const decorator = getMatchDecorator(aggregateEnvs)
|
||||
const envsWithNoEmptyValues =
|
||||
filterNonEmptyEnvironmentVariables(aggregateEnvs)
|
||||
|
||||
const decorator = getMatchDecorator(envsWithNoEmptyValues)
|
||||
|
||||
return ViewPlugin.define(
|
||||
(view) => ({
|
||||
@@ -209,10 +262,45 @@ export class HoppEnvironmentPlugin {
|
||||
subscribeToStream: StreamSubscriberFunc,
|
||||
private editorView: Ref<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) => {
|
||||
this.envs = envs
|
||||
this.envs = [
|
||||
...currentTab.document.request.requestVariables.map(
|
||||
({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
sourceEnv: "RequestVariable",
|
||||
secret: false,
|
||||
})
|
||||
),
|
||||
...envs,
|
||||
]
|
||||
|
||||
this.editorView.value?.dispatch({
|
||||
effects: this.compartment.reconfigure([
|
||||
cursorTooltipField(this.envs),
|
||||
@@ -251,7 +339,7 @@ export class HoppReactiveEnvPlugin {
|
||||
]),
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pipe, flow } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as A from "fp-ts/Array"
|
||||
import { translateToNewRESTCollection, HoppCollection } from "@hoppscotch/data"
|
||||
import { isPlainObject as _isPlainObject } from "lodash-es"
|
||||
|
||||
@@ -9,11 +10,13 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
import { safeParseJSON } from "~/helpers/functional/json"
|
||||
import { translateToNewGQLCollection } from "@hoppscotch/data"
|
||||
|
||||
export const hoppRESTImporter = (content: string) =>
|
||||
export const hoppRESTImporter = (content: string[]) =>
|
||||
pipe(
|
||||
safeParseJSON(content),
|
||||
content,
|
||||
A.traverse(O.Applicative)((str) => safeParseJSON(str, true)),
|
||||
O.chain(
|
||||
flow(
|
||||
A.flatten,
|
||||
makeCollectionsArray,
|
||||
RA.map(validateCollection),
|
||||
O.sequenceArray,
|
||||
|
||||
@@ -8,17 +8,35 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { z } from "zod"
|
||||
|
||||
export const hoppEnvImporter = (content: string) => {
|
||||
const parsedContent = safeParseJSON(content, true)
|
||||
export const hoppEnvImporter = (contents: string[]) => {
|
||||
const parsedContents = contents.map((str) => safeParseJSON(str, true))
|
||||
|
||||
// parse json from the environments string
|
||||
if (O.isNone(parsedContent)) {
|
||||
if (parsedContents.some((parsed) => O.isNone(parsed))) {
|
||||
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
|
||||
.array(entityReference(Environment))
|
||||
.safeParse(parsedContent.value)
|
||||
.safeParse(parsedValues)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
|
||||
|
||||
@@ -3,10 +3,10 @@ import * as E from "fp-ts/Either"
|
||||
|
||||
// TODO: add zod validation
|
||||
export const hoppGqlCollectionsImporter = (
|
||||
content: string
|
||||
contents: string[]
|
||||
): E.Either<"INVALID_JSON", HoppCollection[]> => {
|
||||
return E.tryCatch(
|
||||
() => JSON.parse(content) as HoppCollection[],
|
||||
() => contents.flatMap((content) => JSON.parse(content)),
|
||||
() => "INVALID_JSON"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid"
|
||||
export function FileSource(metadata: {
|
||||
acceptedFileTypes: string
|
||||
caption: string
|
||||
onImportFromFile: (content: string) => any | Promise<any>
|
||||
onImportFromFile: (content: string[]) => any | Promise<any>
|
||||
}) {
|
||||
const stepID = uuidv4()
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ import { v4 as uuidv4 } from "uuid"
|
||||
export function GistSource(metadata: {
|
||||
caption: string
|
||||
onImportFromGist: (
|
||||
importResult: E.Either<string, string>
|
||||
importResult: E.Either<string, string[]>
|
||||
) => any | Promise<any>
|
||||
}) {
|
||||
const stepID = uuidv4()
|
||||
|
||||
return defineStep(stepID, UrlImport, () => ({
|
||||
caption: metadata.caption,
|
||||
onImportFromURL: (gistResponse) => {
|
||||
onImportFromURL: (gistResponse: Record<string, unknown>) => {
|
||||
const fileSchema = z.object({
|
||||
files: z.record(z.object({ content: z.string() })),
|
||||
})
|
||||
@@ -29,9 +29,11 @@ export function GistSource(metadata: {
|
||||
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,
|
||||
}))
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { convert, ImportRequest } from "insomnia-importers"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppRESTAuth,
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
HoppRESTReqBody,
|
||||
HoppRESTRequest,
|
||||
knownContentTypes,
|
||||
makeRESTRequest,
|
||||
HoppCollection,
|
||||
makeCollection,
|
||||
makeRESTRequest,
|
||||
HoppRESTRequestVariable,
|
||||
} from "@hoppscotch/data"
|
||||
|
||||
import * as A from "fp-ts/Array"
|
||||
import * as TO from "fp-ts/TaskOption"
|
||||
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 { 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 InsomniaResource = ImportRequest
|
||||
|
||||
// insomnia-importers v3.6.0 doesn't provide a type for path parameters and they have deprecated the library
|
||||
type InsomniaPathParameter = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type InsomniaFolderResource = ImportRequest & { _type: "request_group" }
|
||||
type InsomniaRequestResource = ImportRequest & { _type: "request" }
|
||||
type InsomniaRequestResource = ImportRequest & {
|
||||
_type: "request"
|
||||
} & {
|
||||
pathParameters?: InsomniaPathParameter[]
|
||||
}
|
||||
|
||||
const parseInsomniaDoc = (content: string) =>
|
||||
TO.tryCatch(() => convert(content))
|
||||
|
||||
const replacePathVarTemplating = (expression: string) =>
|
||||
expression.replaceAll(/:([^/]+)/g, "<<$1>>")
|
||||
|
||||
const replaceVarTemplating = (expression: string) =>
|
||||
replaceInsomniaTemplating(expression)
|
||||
pipe(expression, replacePathVarTemplating, replaceInsomniaTemplating)
|
||||
|
||||
const getFoldersIn = (
|
||||
folder: InsomniaFolderResource | null,
|
||||
@@ -175,6 +191,15 @@ const getHoppReqParams = (req: InsomniaRequestResource): HoppRESTParam[] =>
|
||||
active: !(param.disabled ?? false),
|
||||
})) ?? []
|
||||
|
||||
const getHoppReqVariables = (
|
||||
req: InsomniaRequestResource
|
||||
): HoppRESTRequestVariable[] =>
|
||||
req.pathParameters?.map((variable) => ({
|
||||
key: replaceVarTemplating(variable.name),
|
||||
value: replaceVarTemplating(variable.value ?? ""),
|
||||
active: true,
|
||||
})) ?? []
|
||||
|
||||
const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest =>
|
||||
makeRESTRequest({
|
||||
name: req.name ?? "Untitled Request",
|
||||
@@ -187,6 +212,8 @@ const getHoppRequest = (req: InsomniaRequestResource): HoppRESTRequest =>
|
||||
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
|
||||
requestVariables: getHoppReqVariables(req),
|
||||
})
|
||||
|
||||
const getHoppFolder = (
|
||||
@@ -203,15 +230,18 @@ const getHoppFolder = (
|
||||
headers: [],
|
||||
})
|
||||
|
||||
const getHoppCollections = (doc: InsomniaDoc) =>
|
||||
getFoldersIn(null, doc.data.resources).map((f) =>
|
||||
getHoppFolder(f, doc.data.resources)
|
||||
)
|
||||
const getHoppCollections = (docs: InsomniaDoc[]) => {
|
||||
return docs.flatMap((doc) => {
|
||||
return getFoldersIn(null, doc.data.resources).map((f) =>
|
||||
getHoppFolder(f, doc.data.resources)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const hoppInsomniaImporter = (fileContent: string) =>
|
||||
export const hoppInsomniaImporter = (fileContents: string[]) =>
|
||||
pipe(
|
||||
fileContent,
|
||||
parseInsomniaDoc,
|
||||
fileContents,
|
||||
A.traverse(TO.ApplicativeSeq)(parseInsomniaDoc),
|
||||
TO.map(getHoppCollections),
|
||||
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT)
|
||||
)
|
||||
|
||||
@@ -29,33 +29,36 @@ export const replaceInsomniaTemplating = (expression: string) => {
|
||||
return expression.replaceAll(regex, "<<$1>>")
|
||||
}
|
||||
|
||||
export const insomniaEnvImporter = (content: string) => {
|
||||
const parsedContent = safeParseJSONOrYAML(content)
|
||||
|
||||
if (O.isNone(parsedContent)) {
|
||||
export const insomniaEnvImporter = (contents: string[]) => {
|
||||
const parsedContents = contents.map((str) => safeParseJSONOrYAML(str))
|
||||
if (parsedContents.some((parsed) => O.isNone(parsed))) {
|
||||
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
|
||||
}
|
||||
|
||||
const validationResult = insomniaResourcesSchema.safeParse(
|
||||
parsedContent.value
|
||||
)
|
||||
const parsedValues = parsedContents.map((parsed) => O.toNullable(parsed))
|
||||
|
||||
const validationResult = z
|
||||
.array(insomniaResourcesSchema)
|
||||
.safeParse(parsedValues)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
|
||||
}
|
||||
|
||||
const insomniaEnvs = validationResult.data.resources
|
||||
.filter((resource) => resource._type === "environment")
|
||||
.map((envResource) => {
|
||||
const envResourceData = envResource.data as Record<string, unknown>
|
||||
const stringifiedData: Record<string, string> = {}
|
||||
const insomniaEnvs = validationResult.data.flatMap(({ resources }) => {
|
||||
return resources
|
||||
.filter((resource) => resource._type === "environment")
|
||||
.map((envResource) => {
|
||||
const envResourceData = envResource.data as Record<string, unknown>
|
||||
const stringifiedData: Record<string, string> = {}
|
||||
|
||||
Object.keys(envResourceData).forEach((key) => {
|
||||
stringifiedData[key] = String(envResourceData[key])
|
||||
Object.keys(envResourceData).forEach((key) => {
|
||||
stringifiedData[key] = String(envResourceData[key])
|
||||
})
|
||||
|
||||
return { ...envResource, data: stringifiedData }
|
||||
})
|
||||
|
||||
return { ...envResource, data: stringifiedData }
|
||||
})
|
||||
})
|
||||
|
||||
const environments: NonSecretEnvironment[] = []
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
makeRESTRequest,
|
||||
HoppCollection,
|
||||
makeCollection,
|
||||
HoppRESTRequestVariable,
|
||||
} from "@hoppscotch/data"
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import * as A from "fp-ts/Array"
|
||||
@@ -82,6 +83,27 @@ const parseOpenAPIParams = (params: OpenAPIParamsType[]): HoppRESTParam[] =>
|
||||
)
|
||||
)
|
||||
|
||||
const parseOpenAPIVariables = (
|
||||
variables: OpenAPIParamsType[]
|
||||
): HoppRESTRequestVariable[] =>
|
||||
pipe(
|
||||
variables,
|
||||
|
||||
A.filterMap(
|
||||
flow(
|
||||
O.fromPredicate((param) => param.in === "path"),
|
||||
O.map(
|
||||
(param) =>
|
||||
<HoppRESTRequestVariable>{
|
||||
key: param.name,
|
||||
value: "", // TODO: Can we do anything more ? (parse default values maybe)
|
||||
active: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const parseOpenAPIHeaders = (params: OpenAPIParamsType[]): HoppRESTHeader[] =>
|
||||
pipe(
|
||||
params,
|
||||
@@ -577,6 +599,10 @@ const convertPathToHoppReqs = (
|
||||
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
|
||||
requestVariables: parseOpenAPIVariables(
|
||||
(info.parameters as OpenAPIParamsType[] | undefined) ?? []
|
||||
),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -584,24 +610,28 @@ const convertPathToHoppReqs = (
|
||||
RA.toArray
|
||||
)
|
||||
|
||||
const convertOpenApiDocToHopp = (
|
||||
doc: OpenAPI.Document
|
||||
const convertOpenApiDocsToHopp = (
|
||||
docs: OpenAPI.Document[]
|
||||
): 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 ?? {})
|
||||
.map(([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj))
|
||||
.flat()
|
||||
const paths = Object.entries(doc.paths ?? {})
|
||||
.map(([pathName, pathObj]) =>
|
||||
convertPathToHoppReqs(doc, pathName, pathObj)
|
||||
)
|
||||
.flat()
|
||||
|
||||
return TE.of([
|
||||
makeCollection({
|
||||
return makeCollection({
|
||||
name,
|
||||
folders: [],
|
||||
requests: paths,
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
return TE.of(collections)
|
||||
}
|
||||
|
||||
const parseOpenAPIDocContent = (str: string) =>
|
||||
@@ -614,29 +644,49 @@ const parseOpenAPIDocContent = (str: string) =>
|
||||
)
|
||||
)
|
||||
|
||||
export const hoppOpenAPIImporter = (fileContent: string) =>
|
||||
export const hoppOpenAPIImporter = (fileContents: string[]) =>
|
||||
pipe(
|
||||
// See if we can parse JSON properly
|
||||
fileContent,
|
||||
parseOpenAPIDocContent,
|
||||
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT),
|
||||
fileContents,
|
||||
A.traverse(O.Applicative)(parseOpenAPIDocContent),
|
||||
TE.fromOption(() => {
|
||||
return IMPORTER_INVALID_FILE_FORMAT
|
||||
}),
|
||||
// Try validating, else the importer is invalid file format
|
||||
TE.chainW((obj) =>
|
||||
pipe(
|
||||
TE.chainW((docArr) => {
|
||||
return pipe(
|
||||
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
|
||||
)
|
||||
)
|
||||
),
|
||||
}),
|
||||
// Deference the references
|
||||
TE.chainW((obj) =>
|
||||
TE.chainW((docArr) =>
|
||||
pipe(
|
||||
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
|
||||
)
|
||||
)
|
||||
),
|
||||
TE.chainW(convertOpenApiDocToHopp)
|
||||
TE.chainW(convertOpenApiDocsToHopp)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
QueryParam,
|
||||
RequestAuthDefinition,
|
||||
VariableDefinition,
|
||||
Variable,
|
||||
} from "postman-collection"
|
||||
import {
|
||||
HoppRESTAuth,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ValidContentTypes,
|
||||
knownContentTypes,
|
||||
FormDataKeyValue,
|
||||
HoppRESTRequestVariable,
|
||||
} from "@hoppscotch/data"
|
||||
import { pipe, flow } from "fp-ts/function"
|
||||
import * as S from "fp-ts/string"
|
||||
@@ -55,7 +57,11 @@ const readPMCollection = (def: string) =>
|
||||
pipe(
|
||||
def,
|
||||
safeParseJSON,
|
||||
O.chain((data) => O.tryCatch(() => new PMCollection(data)))
|
||||
O.chain((data) =>
|
||||
O.tryCatch(() => {
|
||||
return new PMCollection(data)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
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<
|
||||
AuthType extends
|
||||
RequestAuthDefinition["type"] = RequestAuthDefinition["type"],
|
||||
@@ -276,6 +301,7 @@ const getHoppRequest = (item: Item): HoppRESTRequest => {
|
||||
params: getHoppReqParams(item),
|
||||
auth: getHoppReqAuth(item),
|
||||
body: getHoppReqBody(item),
|
||||
requestVariables: getHoppReqVariables(item),
|
||||
|
||||
// TODO: Decide about this
|
||||
preRequestScript: "",
|
||||
@@ -296,15 +322,17 @@ const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection =>
|
||||
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(
|
||||
// Try reading
|
||||
fileContent,
|
||||
readPMCollection,
|
||||
fileContents,
|
||||
A.traverse(O.Applicative)(readPMCollection),
|
||||
|
||||
O.map(flow(getHoppCollection, A.of)),
|
||||
O.map(flow(getHoppCollections)),
|
||||
|
||||
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
|
||||
)
|
||||
|
||||
@@ -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 * as O from "fp-ts/Option"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
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({
|
||||
name: z.string(),
|
||||
@@ -18,32 +17,44 @@ const postmanEnvSchema = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
export const postmanEnvImporter = (content: string) => {
|
||||
const parsedContent = safeParseJSON(content)
|
||||
type PostmanEnv = z.infer<typeof postmanEnvSchema>
|
||||
|
||||
// parse json from the environments string
|
||||
if (O.isNone(parsedContent)) {
|
||||
export const postmanEnvImporter = (contents: string[]) => {
|
||||
const parsedContents = contents.map((str) => safeParseJSON(str, true))
|
||||
if (parsedContents.some((parsed) => O.isNone(parsed))) {
|
||||
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) {
|
||||
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
|
||||
}
|
||||
|
||||
const postmanEnv = validationResult.data
|
||||
|
||||
const environment: Environment = {
|
||||
id: uniqueId(),
|
||||
v: 1,
|
||||
name: postmanEnv.name,
|
||||
variables: [],
|
||||
}
|
||||
|
||||
postmanEnv.values.forEach(({ key, value }) =>
|
||||
environment.variables.push({ key, value, secret: false })
|
||||
// Convert `values` to `variables` to match the format expected by the system
|
||||
const environments: Environment[] = validationResult.data.map(
|
||||
({ name, values }) => ({
|
||||
id: uniqueId(),
|
||||
v: 1,
|
||||
name,
|
||||
variables: values.map((entires) => ({ ...entires, secret: false })),
|
||||
})
|
||||
)
|
||||
|
||||
return TE.right(environment)
|
||||
return TE.right(environments)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import { SecretEnvironmentService } from "~/services/secret-environment.service"
|
||||
const secretEnvironmentService = getService(SecretEnvironmentService)
|
||||
|
||||
const unsecretEnvironments = (
|
||||
global: Environment["variables"],
|
||||
selected: Environment
|
||||
selected: Environment,
|
||||
global: Environment["variables"]
|
||||
) => {
|
||||
const resolvedGlobalWithSecrets = global.map((globalVar, index) => {
|
||||
const secretVar = secretEnvironmentService.getSecretEnvironmentVariable(
|
||||
@@ -65,8 +65,8 @@ const unsecretEnvironments = (
|
||||
|
||||
export const getCombinedEnvVariables = () => {
|
||||
const reformedVars = unsecretEnvironments(
|
||||
getGlobalVariables(),
|
||||
getCurrentEnvironment()
|
||||
getCurrentEnvironment(),
|
||||
getGlobalVariables()
|
||||
)
|
||||
return {
|
||||
global: cloneDeep(reformedVars.global),
|
||||
|
||||
@@ -17,4 +17,5 @@ export const getDefaultRESTRequest = (): HoppRESTRequest => ({
|
||||
contentType: null,
|
||||
body: null,
|
||||
},
|
||||
requestVariables: [],
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { arrayFlatMap, arraySort } from "../functional/array"
|
||||
import { toFormData } from "../functional/formData"
|
||||
import { tupleWithSameKeysToRecord } from "../functional/record"
|
||||
import { getGlobalVariables } from "~/newstore/environments"
|
||||
|
||||
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
|
||||
/**
|
||||
@@ -34,6 +33,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
|
||||
effectiveFinalHeaders: { key: string; value: string }[]
|
||||
effectiveFinalParams: { key: string; value: string }[]
|
||||
effectiveFinalBody: FormData | string | null
|
||||
effectiveFinalRequestVariables: { key: string; value: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,38 +313,53 @@ export function getEffectiveRESTRequest(
|
||||
request: HoppRESTRequest,
|
||||
environment: Environment
|
||||
): EffectiveHoppRESTRequest {
|
||||
const envVariables = [...environment.variables, ...getGlobalVariables()]
|
||||
|
||||
const effectiveFinalHeaders = pipe(
|
||||
getComputedHeaders(request, envVariables).map((h) => h.header),
|
||||
getComputedHeaders(request, environment.variables).map((h) => h.header),
|
||||
A.concat(request.headers),
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: parseTemplateString(x.value, envVariables),
|
||||
key: parseTemplateString(x.key, environment.variables),
|
||||
value: parseTemplateString(x.value, environment.variables),
|
||||
}))
|
||||
)
|
||||
|
||||
const effectiveFinalParams = pipe(
|
||||
getComputedParams(request, envVariables).map((p) => p.param),
|
||||
getComputedParams(request, environment.variables).map((p) => p.param),
|
||||
A.concat(request.params),
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, envVariables),
|
||||
value: parseTemplateString(x.value, envVariables),
|
||||
key: parseTemplateString(x.key, environment.variables),
|
||||
value: parseTemplateString(x.value, environment.variables),
|
||||
}))
|
||||
)
|
||||
|
||||
const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables)
|
||||
const effectiveFinalRequestVariables = pipe(
|
||||
request.requestVariables,
|
||||
A.filter((x) => x.active && x.key !== ""),
|
||||
A.map((x) => ({
|
||||
active: true,
|
||||
key: parseTemplateString(x.key, environment.variables),
|
||||
value: parseTemplateString(x.value, environment.variables),
|
||||
}))
|
||||
)
|
||||
|
||||
const effectiveFinalBody = getFinalBodyFromRequest(
|
||||
request,
|
||||
environment.variables
|
||||
)
|
||||
|
||||
return {
|
||||
...request,
|
||||
effectiveFinalURL: parseTemplateString(request.endpoint, envVariables),
|
||||
effectiveFinalURL: parseTemplateString(
|
||||
request.endpoint,
|
||||
environment.variables
|
||||
),
|
||||
effectiveFinalHeaders,
|
||||
effectiveFinalParams,
|
||||
effectiveFinalBody,
|
||||
effectiveFinalRequestVariables,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function navigateToFolderWithIndexPath(
|
||||
|
||||
let target = collections[indexPaths.shift() as number]
|
||||
|
||||
while (indexPaths.length > 0)
|
||||
while (indexPaths.length > 0 && target)
|
||||
target = target.folders[indexPaths.shift() as number]
|
||||
|
||||
return target !== undefined ? target : null
|
||||
|
||||
@@ -353,6 +353,7 @@ executedResponses$.subscribe((res) => {
|
||||
params: res.req.params,
|
||||
preRequestScript: res.req.preRequestScript,
|
||||
testScript: res.req.testScript,
|
||||
requestVariables: res.req.requestVariables,
|
||||
v: res.req.v,
|
||||
},
|
||||
responseMeta: {
|
||||
|
||||
@@ -38,6 +38,7 @@ export type SettingsDef = {
|
||||
httpUrlEncoded: boolean
|
||||
httpPreRequest: boolean
|
||||
httpTest: boolean
|
||||
httpRequestVariables: boolean
|
||||
graphqlQuery: boolean
|
||||
graphqlResponseBody: boolean
|
||||
graphqlHeaders: boolean
|
||||
@@ -79,6 +80,7 @@ export const getDefaultSettings = (): SettingsDef => ({
|
||||
httpUrlEncoded: true,
|
||||
httpPreRequest: true,
|
||||
httpTest: true,
|
||||
httpRequestVariables: true,
|
||||
graphqlQuery: true,
|
||||
graphqlResponseBody: true,
|
||||
graphqlHeaders: false,
|
||||
|
||||
@@ -95,31 +95,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue"
|
||||
import { ref, onMounted } from "vue"
|
||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { platform } from "~/platform"
|
||||
import {
|
||||
audit,
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
from,
|
||||
map,
|
||||
Subscription,
|
||||
} from "rxjs"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import {
|
||||
changeCurrentSyncStatus,
|
||||
currentSyncingStatus$,
|
||||
} from "~/newstore/syncing"
|
||||
import { useService } from "dioc/vue"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
|
||||
@@ -127,7 +111,7 @@ import { EnvironmentInspectorService } from "~/services/inspection/inspectors/en
|
||||
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { HoppTab, PersistableTabState } from "~/services/tab"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
|
||||
const savingRequest = ref(false)
|
||||
@@ -140,7 +124,6 @@ const exceptedTabID = ref<string | null>(null)
|
||||
const renameTabID = ref<string | null>(null)
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const tabs = useService(RESTTabService)
|
||||
|
||||
@@ -171,12 +154,6 @@ const contextMenu = ref<PopupDetails>({
|
||||
|
||||
const activeTabs = tabs.getActiveTabs()
|
||||
|
||||
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
|
||||
isInitialSync: false,
|
||||
shouldSync: true,
|
||||
})
|
||||
const tabStateForSync = ref<PersistableTabState<HoppRESTDocument> | null>(null)
|
||||
|
||||
function bindRequestToURLParams() {
|
||||
const route = useRoute()
|
||||
// Get URL parameters and set that as the request
|
||||
@@ -327,111 +304,6 @@ const shareTabRequest = (tabID: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const syncTabState = () => {
|
||||
if (tabStateForSync.value)
|
||||
tabs.loadTabsFromPersistedState(tabStateForSync.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs sync of the REST Tab session with Firestore.
|
||||
*
|
||||
* @returns A subscription to the sync observable stream.
|
||||
* Unsubscribe to stop syncing.
|
||||
*/
|
||||
function startTabStateSync(): Subscription {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
const tabState$ =
|
||||
new BehaviorSubject<PersistableTabState<HoppRESTDocument> | null>(null)
|
||||
|
||||
watchDebounced(
|
||||
tabs.persistableTabState,
|
||||
(state) => {
|
||||
tabState$.next(state)
|
||||
},
|
||||
{ debounce: 500, deep: true }
|
||||
)
|
||||
|
||||
const sub = combineLatest([currentUser$, tabState$])
|
||||
.pipe(
|
||||
map(([user, tabState]) =>
|
||||
user && tabState
|
||||
? from(platform.sync.tabState.writeCurrentTabState(user, tabState))
|
||||
: EMPTY
|
||||
),
|
||||
audit((x) => x)
|
||||
)
|
||||
.subscribe(() => {
|
||||
// NOTE: This subscription should be kept
|
||||
})
|
||||
|
||||
return sub
|
||||
}
|
||||
|
||||
const showSyncToast = () => {
|
||||
toast.show(t("confirm.sync"), {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.yes")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
syncTabState()
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: true,
|
||||
})
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("action.no")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: false,
|
||||
})
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function setupTabStateSync() {
|
||||
const route = useRoute()
|
||||
|
||||
// Subscription to request sync
|
||||
let sub: Subscription | null = null
|
||||
|
||||
// Load request on login resolve and start sync
|
||||
onLoggedIn(async () => {
|
||||
if (
|
||||
Object.keys(route.query).length === 0 &&
|
||||
!(route.query.code || route.query.error)
|
||||
) {
|
||||
const tabStateFromSync =
|
||||
await platform.sync.tabState.loadTabStateFromSync()
|
||||
|
||||
if (tabStateFromSync && !confirmSync.value.isInitialSync) {
|
||||
tabStateForSync.value = tabStateFromSync
|
||||
showSyncToast()
|
||||
// Have to set isInitialSync to true here because the toast is shown
|
||||
// and the user does not click on any of the actions
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sub = startTabStateSync()
|
||||
})
|
||||
|
||||
// Stop subscription to stop syncing
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||
if (text) {
|
||||
contextMenu.value = {
|
||||
@@ -448,7 +320,6 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||
}
|
||||
})
|
||||
|
||||
setupTabStateSync()
|
||||
bindRequestToURLParams()
|
||||
|
||||
defineActionHandler("rest.request.open", ({ doc }) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { EnvironmentsPlatformDef } from "./environments"
|
||||
import { CollectionsPlatformDef } from "./collections"
|
||||
import { SettingsPlatformDef } from "./settings"
|
||||
import { HistoryPlatformDef } from "./history"
|
||||
import { TabStatePlatformDef } from "./tab"
|
||||
import { AnalyticsPlatformDef } from "./analytics"
|
||||
import { InterceptorsPlatformDef } from "./interceptors"
|
||||
import { HoppModule } from "~/modules"
|
||||
@@ -25,7 +24,6 @@ export type PlatformDef = {
|
||||
collections: CollectionsPlatformDef
|
||||
settings: SettingsPlatformDef
|
||||
history: HistoryPlatformDef
|
||||
tabState: TabStatePlatformDef
|
||||
}
|
||||
interceptors: InterceptorsPlatformDef
|
||||
additionalInspectors?: InspectorsPlatformDef
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { PersistableTabState } from "~/services/tab"
|
||||
import { HoppUser } from "./auth"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
|
||||
export type TabStatePlatformDef = {
|
||||
loadTabStateFromSync: () => Promise<PersistableTabState<HoppRESTDocument> | null>
|
||||
writeCurrentTabState: (
|
||||
user: HoppUser,
|
||||
persistableTabState: PersistableTabState<HoppRESTDocument>
|
||||
) => Promise<void>
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Ref, markRaw } from "vue"
|
||||
import IconPlusCircle from "~icons/lucide/plus-circle"
|
||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
import {
|
||||
AggregateEnvironment,
|
||||
aggregateEnvsWithSecrets$,
|
||||
getCurrentEnvironment,
|
||||
getSelectedEnvironmentType,
|
||||
@@ -18,6 +19,7 @@ import { invokeAction } from "~/helpers/actions"
|
||||
import { computed } from "vue"
|
||||
import { useStreamStatic } from "~/composables/stream"
|
||||
import { SecretEnvironmentService } from "~/services/secret-environment.service"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
|
||||
|
||||
@@ -41,6 +43,7 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
|
||||
private readonly inspection = this.bind(InspectionService)
|
||||
private readonly secretEnvs = this.bind(SecretEnvironmentService)
|
||||
private readonly restTabs = this.bind(RESTTabService)
|
||||
|
||||
private aggregateEnvsWithSecrets = useStreamStatic(
|
||||
aggregateEnvsWithSecrets$,
|
||||
@@ -68,7 +71,14 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
) => {
|
||||
const newErrors: InspectorResult[] = []
|
||||
|
||||
const envKeys = this.aggregateEnvsWithSecrets.value.map((e) => e.key)
|
||||
const currentTab = this.restTabs.currentActiveTab.value
|
||||
|
||||
const environmentVariables = [
|
||||
...currentTab.document.request.requestVariables,
|
||||
...this.aggregateEnvsWithSecrets.value,
|
||||
]
|
||||
|
||||
const envKeys = environmentVariables.map((e) => e.key)
|
||||
|
||||
target.forEach((element, index) => {
|
||||
if (isENVInString(element)) {
|
||||
@@ -124,6 +134,31 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
return newErrors
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the environment list to a list with unique keys with value
|
||||
* @param envs The environment list to be transformed
|
||||
* @returns The transformed environment list with keys with value
|
||||
*/
|
||||
private filterNonEmptyEnvironmentVariables = (
|
||||
envs: AggregateEnvironment[]
|
||||
): AggregateEnvironment[] => {
|
||||
const envsMap = new Map<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())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the environment variables in the target array are empty
|
||||
* @param target The target array to validate
|
||||
@@ -145,7 +180,19 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
const formattedExEnv = exEnv.slice(2, -2)
|
||||
const currentSelectedEnvironment = getCurrentEnvironment()
|
||||
|
||||
this.aggregateEnvsWithSecrets.value.forEach((env) => {
|
||||
const currentTab = this.restTabs.currentActiveTab.value
|
||||
|
||||
const environmentVariables =
|
||||
this.filterNonEmptyEnvironmentVariables([
|
||||
...currentTab.document.request.requestVariables.map((env) => ({
|
||||
...env,
|
||||
secret: false,
|
||||
sourceEnv: "RequestVariable",
|
||||
})),
|
||||
...this.aggregateEnvsWithSecrets.value,
|
||||
])
|
||||
|
||||
environmentVariables.forEach((env) => {
|
||||
const hasSecretEnv = this.secretEnvs.hasSecretValue(
|
||||
env.sourceEnv !== "Global"
|
||||
? currentSelectedEnvironment.id
|
||||
@@ -199,14 +246,19 @@ export class EnvironmentInspectorService extends Service implements Inspector {
|
||||
"inspections.environment.add_environment_value"
|
||||
),
|
||||
apply: () => {
|
||||
invokeAction(invokeActionType, {
|
||||
envName:
|
||||
env.sourceEnv !== "Global"
|
||||
? currentSelectedEnvironment.name
|
||||
: "Global",
|
||||
variableName: formattedExEnv,
|
||||
isSecret: env.secret,
|
||||
})
|
||||
if (env.sourceEnv === "RequestVariable") {
|
||||
currentTab.document.optionTabPreference =
|
||||
"requestVariables"
|
||||
} else {
|
||||
invokeAction(invokeActionType, {
|
||||
envName:
|
||||
env.sourceEnv === "Global"
|
||||
? "Global"
|
||||
: currentSelectedEnvironment.name,
|
||||
variableName: formattedExEnv,
|
||||
isSecret: env.secret,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
severity: 2,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
folders: [],
|
||||
requests: [
|
||||
{
|
||||
v: "1",
|
||||
v: "2",
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Echo test",
|
||||
params: [],
|
||||
@@ -35,6 +35,7 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
body: { contentType: null, body: null },
|
||||
requestVariables: [],
|
||||
},
|
||||
],
|
||||
auth: { authType: "none", authActive: true },
|
||||
@@ -136,7 +137,8 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
|
||||
params: [],
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
v: "1",
|
||||
requestVariables: [],
|
||||
v: "2",
|
||||
},
|
||||
responseMeta: { duration: 807, statusCode: 200 },
|
||||
star: false,
|
||||
@@ -192,7 +194,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
|
||||
tabID: "e6e8d800-caa8-44a2-a6a6-b4765a3167aa",
|
||||
doc: {
|
||||
request: {
|
||||
v: "1",
|
||||
v: "2",
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Echo test",
|
||||
params: [],
|
||||
@@ -202,6 +204,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRESTDocument> = {
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
body: { contentType: null, body: null },
|
||||
requestVariables: [],
|
||||
},
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
|
||||
@@ -48,21 +48,22 @@ const SettingsDefSchema = z.object({
|
||||
|
||||
WRAP_LINES: z.optional(
|
||||
z.object({
|
||||
httpRequestBody: z.boolean(),
|
||||
httpResponseBody: z.boolean(),
|
||||
httpHeaders: z.boolean(),
|
||||
httpParams: z.boolean(),
|
||||
httpUrlEncoded: z.boolean(),
|
||||
httpPreRequest: z.boolean(),
|
||||
httpTest: z.boolean(),
|
||||
graphqlQuery: z.boolean(),
|
||||
graphqlResponseBody: z.boolean(),
|
||||
graphqlHeaders: z.boolean(),
|
||||
graphqlVariables: z.boolean(),
|
||||
graphqlSchema: z.boolean(),
|
||||
importCurl: z.boolean(),
|
||||
codeGen: z.boolean(),
|
||||
cookie: z.boolean(),
|
||||
httpRequestBody: z.boolean().catch(true),
|
||||
httpResponseBody: z.boolean().catch(true),
|
||||
httpHeaders: z.boolean().catch(true),
|
||||
httpParams: z.boolean().catch(true),
|
||||
httpUrlEncoded: z.boolean().catch(true),
|
||||
httpPreRequest: z.boolean().catch(true),
|
||||
httpTest: z.boolean().catch(true),
|
||||
httpRequestVariables: z.boolean().catch(true),
|
||||
graphqlQuery: z.boolean().catch(true),
|
||||
graphqlResponseBody: z.boolean().catch(true),
|
||||
graphqlHeaders: z.boolean().catch(false),
|
||||
graphqlVariables: z.boolean().catch(false),
|
||||
graphqlSchema: z.boolean().catch(true),
|
||||
importCurl: z.boolean().catch(true),
|
||||
codeGen: z.boolean().catch(true),
|
||||
cookie: z.boolean().catch(true),
|
||||
})
|
||||
),
|
||||
})
|
||||
@@ -514,6 +515,7 @@ const validRestOperations = [
|
||||
"authorization",
|
||||
"preRequestScript",
|
||||
"tests",
|
||||
"requestVariables",
|
||||
] as const
|
||||
|
||||
export const REST_TAB_STATE_SCHEMA = z
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as S from "fp-ts/string"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import V0_VERSION from "./v/0"
|
||||
import V1_VERSION from "./v/1"
|
||||
import V2_VERSION from "./v/2"
|
||||
import { createVersionedEntity, InferredEntity } from "verzod"
|
||||
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "../utils/eq"
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
HoppRESTHeaders,
|
||||
HoppRESTParams,
|
||||
} from "./v/1"
|
||||
import { HoppRESTRequestVariables } from "./v/2"
|
||||
import { z } from "zod"
|
||||
|
||||
export * from "./content-types"
|
||||
@@ -28,16 +30,19 @@ export {
|
||||
HoppRESTHeaders,
|
||||
} from "./v/1"
|
||||
|
||||
export { HoppRESTRequestVariables } from "./v/2"
|
||||
|
||||
const versionedObject = z.object({
|
||||
// v is a stringified number
|
||||
v: z.string().regex(/^\d+$/).transform(Number),
|
||||
})
|
||||
|
||||
export const HoppRESTRequest = createVersionedEntity({
|
||||
latestVersion: 1,
|
||||
latestVersion: 2,
|
||||
versionMap: {
|
||||
0: V0_VERSION,
|
||||
1: V1_VERSION,
|
||||
2: V2_VERSION,
|
||||
},
|
||||
getVersion(data) {
|
||||
// For V1 onwards we have the v string storing the number
|
||||
@@ -73,12 +78,18 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
|
||||
name: S.Eq,
|
||||
preRequestScript: S.Eq,
|
||||
testScript: S.Eq,
|
||||
requestVariables: mapThenEq(
|
||||
(arr) => arr.filter((v: any) => v.key !== "" && v.value !== ""),
|
||||
lodashIsEqualEq
|
||||
),
|
||||
})
|
||||
|
||||
export const RESTReqSchemaVersion = "1"
|
||||
export const RESTReqSchemaVersion = "2"
|
||||
|
||||
export type HoppRESTParam = HoppRESTRequest["params"][number]
|
||||
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
|
||||
export type HoppRESTRequestVariable =
|
||||
HoppRESTRequest["requestVariables"][number]
|
||||
|
||||
export const isEqualHoppRESTRequest = HoppRESTRequestEq.equals
|
||||
|
||||
@@ -144,6 +155,14 @@ export function safelyExtractRESTRequest(
|
||||
req.headers = result.data
|
||||
}
|
||||
}
|
||||
|
||||
if ("requestVariables" in x) {
|
||||
const result = HoppRESTRequestVariables.safeParse(x.requestVariables)
|
||||
|
||||
if (result.success) {
|
||||
req.requestVariables = result.data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
@@ -160,7 +179,7 @@ export function makeRESTRequest(
|
||||
|
||||
export function getDefaultRESTRequest(): HoppRESTRequest {
|
||||
return {
|
||||
v: "1",
|
||||
v: "2",
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
name: "Untitled",
|
||||
params: [],
|
||||
@@ -176,6 +195,7 @@ export function getDefaultRESTRequest(): HoppRESTRequest {
|
||||
contentType: null,
|
||||
body: null,
|
||||
},
|
||||
requestVariables: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export const HoppRESTHeaders = z.array(
|
||||
|
||||
export type HoppRESTHeaders = z.infer<typeof HoppRESTHeaders>
|
||||
|
||||
const V1_SCHEMA = z.object({
|
||||
export const V1_SCHEMA = z.object({
|
||||
v: z.literal("1"),
|
||||
id: z.optional(z.string()), // Firebase Firestore ID
|
||||
|
||||
@@ -158,7 +158,7 @@ const V1_SCHEMA = z.object({
|
||||
body: HoppRESTReqBody,
|
||||
})
|
||||
|
||||
function parseRequestBody(
|
||||
export function parseRequestBody(
|
||||
x: z.infer<typeof V0_SCHEMA>
|
||||
): z.infer<typeof V1_SCHEMA>["body"] {
|
||||
return {
|
||||
|
||||
50
packages/hoppscotch-data/src/rest/v/2.ts
Normal file
50
packages/hoppscotch-data/src/rest/v/2.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineVersion } from "verzod"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
HoppRESTAuth,
|
||||
HoppRESTHeaders,
|
||||
HoppRESTParams,
|
||||
HoppRESTReqBody,
|
||||
} from "./1"
|
||||
import { V1_SCHEMA } from "./1"
|
||||
|
||||
export const HoppRESTRequestVariables = z.array(
|
||||
z.object({
|
||||
key: z.string().catch(""),
|
||||
value: z.string().catch(""),
|
||||
active: z.boolean().catch(true),
|
||||
})
|
||||
)
|
||||
|
||||
export type HoppRESTRequestVariables = z.infer<typeof HoppRESTRequestVariables>
|
||||
|
||||
const V2_SCHEMA = z.object({
|
||||
v: z.literal("2"),
|
||||
id: z.optional(z.string()), // Firebase Firestore ID
|
||||
|
||||
name: z.string(),
|
||||
method: z.string(),
|
||||
endpoint: z.string(),
|
||||
params: HoppRESTParams,
|
||||
headers: HoppRESTHeaders,
|
||||
preRequestScript: z.string().catch(""),
|
||||
testScript: z.string().catch(""),
|
||||
|
||||
auth: HoppRESTAuth,
|
||||
|
||||
body: HoppRESTReqBody,
|
||||
|
||||
requestVariables: HoppRESTRequestVariables,
|
||||
})
|
||||
|
||||
export default defineVersion({
|
||||
initial: false,
|
||||
schema: V2_SCHEMA,
|
||||
up(old: z.infer<typeof V1_SCHEMA>) {
|
||||
return {
|
||||
...old,
|
||||
v: "2",
|
||||
requestVariables: [],
|
||||
} as z.infer<typeof V2_SCHEMA>
|
||||
},
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
mutation UpdateUserSession(
|
||||
$currentSession: String!
|
||||
$sessionType: SessionType!
|
||||
) {
|
||||
updateUserSessions(
|
||||
currentSession: $currentSession
|
||||
sessionType: $sessionType
|
||||
) {
|
||||
currentRESTSession
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
query GetCurrentRESTSession {
|
||||
me {
|
||||
currentRESTSession
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user