diff --git a/packages/hoppscotch-backend/Dockerfile b/packages/hoppscotch-backend/Dockerfile index ab0c88f42..ed9736551 100644 --- a/packages/hoppscotch-backend/Dockerfile +++ b/packages/hoppscotch-backend/Dockerfile @@ -6,10 +6,10 @@ WORKDIR /usr/src/app RUN npm i -g pnpm # Prisma bits -COPY prisma ./ +COPY prisma ./prisma/ RUN pnpx prisma generate -# # NPM package install +# # PNPM package install COPY . . RUN pnpm i diff --git a/packages/hoppscotch-backend/prisma/migrations/20221213074249_create_user_environments/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20221213074249_create_user_environments/migration.sql new file mode 100644 index 000000000..09af18a03 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20221213074249_create_user_environments/migration.sql @@ -0,0 +1,129 @@ +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR'); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "role" "TeamMemberRole" NOT NULL, + "userUid" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamInvitation" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "inviteeEmail" TEXT NOT NULL, + "inviteeRole" "TeamMemberRole" NOT NULL, + + CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamCollection" ( + "id" TEXT NOT NULL, + "parentID" TEXT, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamRequest" ( + "id" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "title" TEXT NOT NULL, + "request" JSONB NOT NULL, + + CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Shortcode" ( + "id" TEXT NOT NULL, + "request" JSONB NOT NULL, + "creatorUid" TEXT, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEnvironment" ( + "id" TEXT NOT NULL, + "teamID" TEXT NOT NULL, + "name" TEXT NOT NULL, + "variables" JSONB NOT NULL, + + CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "uid" TEXT NOT NULL, + "displayName" TEXT, + "email" TEXT, + "photoURL" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "UserEnvironment" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "name" TEXT, + "variables" JSONB NOT NULL, + "isGlobal" BOOLEAN NOT NULL, + + CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid"); + +-- CreateIndex +CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail"); + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/20230123090914_craete_user_settings/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20230123090914_craete_user_settings/migration.sql new file mode 100644 index 000000000..f0ec24276 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20230123090914_craete_user_settings/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "UserSettings" ( + "id" TEXT NOT NULL, + "userUid" TEXT NOT NULL, + "settings" JSONB NOT NULL, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid"); + +-- AddForeignKey +ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 2d10e28cd..ab3346a23 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -79,11 +79,12 @@ model TeamEnvironment { } model User { - uid String @id @default(cuid()) - displayName String? - email String? - photoURL String? - settings UserSettings? + uid String @id @default(cuid()) + displayName String? + email String? + photoURL String? + settings UserSettings? + UserEnvironments UserEnvironment[] } model UserSettings { @@ -94,6 +95,15 @@ model UserSettings { updatedOn DateTime @updatedAt @db.Timestamp(3) } +model UserEnvironment { + id String @id @default(cuid()) + userUid String + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + name String? + variables Json + isGlobal Boolean +} + enum TeamMemberRole { OWNER VIEWER diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 92c105f00..3ba7c17b2 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -4,6 +4,7 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { UserModule } from './user/user.module'; import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin'; import { UserSettingsModule } from './user-settings/user-settings.module'; +import { UserEnvironmentsModule } from './user-environment/user-environments.module'; @Module({ imports: [ @@ -45,7 +46,8 @@ import { UserSettingsModule } from './user-settings/user-settings.module'; driver: ApolloDriver, }), UserModule, - UserSettingsModule + UserSettingsModule, + UserEnvironmentsModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index da6426178..706d660d5 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -167,7 +167,6 @@ export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER = */ export const USER_SETTINGS_DATA_NOT_FOUND = 'user_settings/data_not_found' as const; - /** * User setting not found for a user * (UserSettingsService) @@ -180,7 +179,59 @@ export const USER_SETTINGS_ALREADY_EXISTS = 'user_settings/settings_already_pres */ export const USER_SETTINGS_NULL_SETTINGS = 'user_settings/null_settings' as const; +/* + * Global environment doesnt exists for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS = + 'user_environment/global_env_does_not_exists' as const; +/** + * Global environment already exists for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS = + 'user_environment/global_env_already_exists' as const; +/* + +/** + * User environment doesn't exist for the user + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS = + 'user_environment/user_env_does_not_exists' as const; +/* + +/** + * Cannot delete the global user environment + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED = + 'user_environment/user_env_global_env_deletion_failed' as const; +/* + +/** + * User environment is not a global environment + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_IS_NOT_GLOBAL = + 'user_environment/user_env_is_not_global' as const; +/* + +/** + * User environment update failed + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_UPDATE_FAILED = + 'user_environment/user_env_update_failed' as const; +/* + +/** + * User environment invalid environment name + * (UserEnvironmentsService) + */ +export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME = + 'user_environment/user_env_invalid_env_name' as const; /* |------------------------------------| diff --git a/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts b/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts index 475768712..bec396028 100644 --- a/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts +++ b/packages/hoppscotch-backend/src/pubsub/pubsub.service.ts @@ -4,6 +4,7 @@ import { default as Redis, RedisOptions } from 'ioredis'; import { RedisPubSub } from 'graphql-redis-subscriptions'; import { PubSub as LocalPubSub } from 'graphql-subscriptions'; +import { TopicDef } from './topicsDefs'; /** * RedisPubSub uses JSON parsing for back and forth conversion, which loses Date objects, hence this reviver brings them back @@ -70,7 +71,7 @@ export class PubSubService implements OnModuleInit { return this.pubsub.asyncIterator(topic, options); } - async publish(topic: string, payload: any) { + async publish(topic: T, payload: TopicDef[T]) { await this.pubsub.publish(topic, payload); } } diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts new file mode 100644 index 000000000..d16c26b4c --- /dev/null +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -0,0 +1,14 @@ +import { UserSettings } from 'src/user-settings/user-settings.model'; +import { UserEnvironment } from '../user-environment/user-environments.model'; + +// A custom message type that defines the topic and the corresponding payload. +// For every module that publishes a subscription add its type def and the possible subscription type. +export type TopicDef = { + [ + topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}` + ]: UserEnvironment; + [ + topic: `user_settings/${string}/${'created' | 'updated' | 'deleted'}` + ]: UserSettings; + [topic: `user_environment/${string}/deleted_many`]: number; +}; diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts new file mode 100644 index 000000000..be99821e3 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts @@ -0,0 +1,30 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UserEnvironment { + @Field(() => ID, { + description: 'ID of the User Environment', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the user this environment belongs to', + }) + userUid: string; + + @Field(() => String, { + nullable: true, + description: 'Name of the environment', + }) + name: string | null | undefined; // types have a union to avoid TS warnings and field is nullable when it is global env + + @Field({ + description: 'All variables present in the environment', + }) + variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client + + @Field({ + description: 'Flag to indicate the environment is global or not', + }) + isGlobal: boolean; +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts new file mode 100644 index 000000000..04f391682 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PubSubModule } from '../pubsub/pubsub.module'; +import { UserModule } from '../user/user.module'; +import { UserEnvsUserResolver } from './user.resolver'; +import { UserEnvironmentsResolver } from './user-environments.resolver'; +import { UserEnvironmentsService } from './user-environments.service'; + +@Module({ + imports: [PrismaModule, PubSubModule, UserModule], + providers: [ + UserEnvironmentsResolver, + UserEnvironmentsService, + UserEnvsUserResolver, + ], + exports: [UserEnvironmentsService], +}) +export class UserEnvironmentsModule {} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts new file mode 100644 index 000000000..cccdf11cd --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts @@ -0,0 +1,207 @@ +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { UserEnvironment } from './user-environments.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { User } from '../user/user.model'; +import { UserEnvironmentsService } from './user-environments.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; + +@Resolver() +export class UserEnvironmentsResolver { + constructor( + private readonly userEnvironmentsService: UserEnvironmentsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => UserEnvironment, { + description: 'Create a new personal user environment for given user uid', + }) + @UseGuards(GqlAuthGuard) + async createUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const isGlobal = false; + const userEnvironment = + await this.userEnvironmentsService.createUserEnvironment( + user.uid, + name, + variables, + isGlobal, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: 'Create a new global user environment for given user uid', + }) + @UseGuards(GqlAuthGuard) + async createUserGlobalEnvironment( + @GqlUser() user: User, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const isGlobal = true; + const userEnvironment = + await this.userEnvironmentsService.createUserEnvironment( + user.uid, + null, + variables, + isGlobal, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: 'Updates a users personal or global environment', + }) + @UseGuards(GqlAuthGuard) + async updateUserEnvironment( + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.updateUserEnvironment( + id, + name, + variables, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => Boolean, { + description: 'Deletes a users personal environment', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.deleteUserEnvironment(user.uid, id); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => Number, { + description: 'Deletes all of users personal environments', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironments(@GqlUser() user: User): Promise { + return await this.userEnvironmentsService.deleteUserEnvironments(user.uid); + } + + @Mutation(() => UserEnvironment, { + description: 'Deletes all variables inside a users global environment', + }) + @UseGuards(GqlAuthGuard) + async clearGlobalEnvironments( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the users global environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.clearGlobalEnvironments(user.uid, id); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + /* Subscriptions */ + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment Creation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentCreated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user_environment/${user.uid}/created`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment updates', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentUpdated( + @Args({ + name: 'id', + description: 'Environment id', + type: () => ID, + }) + id: string, + ) { + return this.pubsub.asyncIterator(`user_environment/${id}/updated`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment deletion', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentDeleted( + @Args({ + name: 'id', + description: 'Environment id', + type: () => ID, + }) + id: string, + ) { + return this.pubsub.asyncIterator(`user_environment/${id}/deleted`); + } + + @Subscription(() => Number, { + description: 'Listen for User Environment DeleteMany', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentDeleteMany(@GqlUser() user: User) { + return this.pubsub.asyncIterator( + `user_environment/${user.uid}/deleted_many`, + ); + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts new file mode 100644 index 000000000..849b589e1 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.service.spec.ts @@ -0,0 +1,565 @@ +import { UserEnvironment } from './user-environments.model'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from '../prisma/prisma.service'; +import { UserEnvironmentsService } from './user-environments.service'; +import { + USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS, + USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED, + USER_ENVIRONMENT_GLOBAL_ENV_EXISTS, + USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME, +} from '../errors'; +import { PubSubService } from '../pubsub/pubsub.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const userEnvironmentsService = new UserEnvironmentsService( + mockPrisma, + mockPubSub as any, +); + +const userPersonalEnvironments = [ + { + userUiD: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }, + { + userUiD: 'abc123', + id: '1234', + name: 'test2', + variables: [{}], + isGlobal: false, + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('UserEnvironmentsService', () => { + describe('fetchUserEnvironments', () => { + test('Should return a list of users personal environments', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([ + { + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }, + { + userUid: 'abc123', + id: '1234', + name: 'test2', + variables: [{}], + isGlobal: false, + }, + ]); + + const userEnvironments: UserEnvironment[] = [ + { + userUid: userPersonalEnvironments[0].userUiD, + id: userPersonalEnvironments[0].id, + name: userPersonalEnvironments[0].name, + variables: JSON.stringify(userPersonalEnvironments[0].variables), + isGlobal: userPersonalEnvironments[0].isGlobal, + }, + { + userUid: userPersonalEnvironments[1].userUiD, + id: userPersonalEnvironments[1].id, + name: userPersonalEnvironments[1].name, + variables: JSON.stringify(userPersonalEnvironments[1].variables), + isGlobal: userPersonalEnvironments[1].isGlobal, + }, + ]; + return expect( + await userEnvironmentsService.fetchUserEnvironments('abc123'), + ).toEqual(userEnvironments); + }); + + test('Should return an empty list of users personal environments', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([]); + + return expect( + await userEnvironmentsService.fetchUserEnvironments('testuser'), + ).toEqual([]); + }); + + test('Should return an empty list of users personal environments if user uid is invalid', async () => { + mockPrisma.userEnvironment.findMany.mockResolvedValueOnce([]); + + return expect( + await userEnvironmentsService.fetchUserEnvironments('invaliduid'), + ).toEqual([]); + }); + }); + + describe('fetchUserGlobalEnvironment', () => { + test('Should resolve right and return a Global Environment for the uid', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + id: 'genv1', + userUid: 'abc', + name: '', + variables: [{}], + isGlobal: true, + }); + + expect( + await userEnvironmentsService.fetchUserGlobalEnvironment('abc'), + ).toEqualRight({ + id: 'genv1', + userUid: 'abc', + name: '', + variables: JSON.stringify([{}]), + isGlobal: true, + }); + }); + + test('Should resolve left and return an error if global env it doesnt exists', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + expect( + await userEnvironmentsService.fetchUserGlobalEnvironment('abc'), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + }); + }); + + describe('createUserEnvironment', () => { + test('Should resolve right and create a users personal environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + 'test', + '[{}]', + false, + ), + ).toEqualRight(result); + }); + + test('Should resolve right and create a new users global environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + true, + ), + ).toEqualRight(result); + }); + + test('Should resolve left and not create a new users global environment if existing global env exists ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + true, + ), + ).toEqualLeft(USER_ENVIRONMENT_GLOBAL_ENV_EXISTS); + }); + + test('Should resolve left when an invalid personal environment name has been passed', async () => { + return expect( + await userEnvironmentsService.createUserEnvironment( + 'abc123', + null, + '[{}]', + false, + ), + ).toEqualLeft(USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME); + }); + + test('Should create a users personal environment and publish a created subscription', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.createUserEnvironment( + 'abc123', + 'test', + '[{}]', + false, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/created`, + result, + ); + }); + + test('Should create a users global environment and publish a created subscription', async () => { + mockPrisma.userEnvironment.create.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: '', + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: '', + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + await userEnvironmentsService.createUserEnvironment( + 'abc123', + '', + '[{}]', + true, + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.userUid}/created`, + result, + ); + }); + }); + + describe('UpdateUserEnvironment', () => { + test('Should resolve right and update a users personal or environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + ), + ).toEqualRight(result); + }); + + test('Should resolve right and update a users global environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + null, + '[{}]', + ), + ).toEqualRight(result); + }); + + test('Should resolve left and not update a users environment if env doesnt exist ', async () => { + mockPrisma.userEnvironment.update.mockRejectedValueOnce( + 'RejectOnNotFound', + ); + + return expect( + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + ), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + }); + + test('Should update a users personal environment and publish an updated subscription ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: 'test', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: 'test', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + 'test', + '[{}]', + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.id}/updated`, + result, + ); + }); + + test('Should update a users global environment and publish an updated subscription ', async () => { + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: '123', + name: null, + variables: [{}], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: '123', + name: null, + variables: JSON.stringify([{}]), + isGlobal: true, + }; + + await userEnvironmentsService.updateUserEnvironment( + 'abc123', + null, + '[{}]', + ); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.id}/updated`, + result, + ); + }); + }); + + describe('deleteUserEnvironment', () => { + test('Should resolve right and delete a users personal environment and return a `UserEnvironment` object ', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.delete.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: false, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'), + ).toEqualRight(true); + }); + + test('Should resolve left and return an error when deleting a global user environment', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'genv1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'genv1'), + ).toEqualLeft(USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED); + }); + + test('Should resolve left and return an error when deleting an invalid user environment', async () => { + mockPrisma.userEnvironment.delete.mockResolvedValueOnce(null); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + }); + + test('Should resolve right, delete a users personal environment and publish a deleted subscription', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce(null); + mockPrisma.userEnvironment.delete.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: false, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([{}]), + isGlobal: false, + }; + + await userEnvironmentsService.deleteUserEnvironment('abc123', 'env1'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.id}/deleted`, + result, + ); + }); + }); + + describe('deleteUserEnvironments', () => { + test('Should publish a subscription with a count of deleted environments', async () => { + mockPrisma.userEnvironment.deleteMany.mockResolvedValueOnce({ + count: 1, + }); + + await userEnvironmentsService.deleteUserEnvironments('abc123'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${'abc123'}/deleted_many`, + 1, + ); + }); + }); + + describe('clearGlobalEnvironments', () => { + test('Should resolve right and delete all variables inside users global environment and return a `UserEnvironment` object', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([]), + isGlobal: true, + }; + + return expect( + await userEnvironmentsService.clearGlobalEnvironments('abc123', 'env1'), + ).toEqualRight(result); + }); + + test('Should resolve left and return an error if global environment id and passed id dont match', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'genv2', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + return expect( + await userEnvironmentsService.deleteUserEnvironment('abc123', 'genv1'), + ).toEqualLeft(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + }); + + test('Should resolve right,delete all variables inside users global environment and publish an updated subscription', async () => { + mockPrisma.userEnvironment.findFirst.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [{}], + isGlobal: true, + }); + + mockPrisma.userEnvironment.update.mockResolvedValueOnce({ + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: [], + isGlobal: true, + }); + + const result: UserEnvironment = { + userUid: 'abc123', + id: 'env1', + name: 'en1', + variables: JSON.stringify([]), + isGlobal: true, + }; + + await userEnvironmentsService.clearGlobalEnvironments('abc123', 'env1'); + + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_environment/${result.id}/updated`, + result, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts new file mode 100644 index 000000000..9daa9cb46 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts @@ -0,0 +1,278 @@ +import { Injectable } from '@nestjs/common'; +import { UserEnvironment } from './user-environments.model'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { + USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS, + USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS, + USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED, + USER_ENVIRONMENT_GLOBAL_ENV_EXISTS, + USER_ENVIRONMENT_IS_NOT_GLOBAL, + USER_ENVIRONMENT_UPDATE_FAILED, + USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME, +} from '../errors'; +import { stringToJson } from '../utils'; + +@Injectable() +export class UserEnvironmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + /** + * Fetch personal user environments + * @param uid Users uid + * @returns array of users personal environments + */ + async fetchUserEnvironments(uid: string) { + const environments = await this.prisma.userEnvironment.findMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + + const userEnvironments: UserEnvironment[] = []; + environments.forEach((environment) => { + userEnvironments.push({ + userUid: environment.userUid, + id: environment.id, + name: environment.name, + variables: JSON.stringify(environment.variables), + isGlobal: environment.isGlobal, + }); + }); + return userEnvironments; + } + + /** + * Fetch users global environment + * @param uid Users uid + * @returns an `UserEnvironment` object + */ + async fetchUserGlobalEnvironment(uid: string) { + const globalEnvironment = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + }); + + if (globalEnvironment != null) { + return E.right({ + userUid: globalEnvironment.userUid, + id: globalEnvironment.id, + name: globalEnvironment.name, + variables: JSON.stringify(globalEnvironment.variables), + isGlobal: globalEnvironment.isGlobal, + }); + } + + return E.left(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + } + + /** + * Create a personal or global user environment + * @param uid Users uid + * @param name environments name, null if the environment is global + * @param variables environment variables + * @param isGlobal flag to indicate type of environment to create + * @returns an `UserEnvironment` object + */ + async createUserEnvironment( + uid: string, + name: string, + variables: string, + isGlobal: boolean, + ) { + // Check for existing global env for a user if exists error out to avoid recreation + if (isGlobal) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (!O.isNone(globalEnvExists)) + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_EXISTS); + } + if (name === null && !isGlobal) + return E.left(USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME); + + const envVariables = stringToJson(variables); + if (E.isLeft(envVariables)) return E.left(envVariables.left); + const createdEnvironment = await this.prisma.userEnvironment.create({ + data: { + userUid: uid, + name: name, + variables: envVariables.right, + isGlobal: isGlobal, + }, + }); + + const userEnvironment: UserEnvironment = { + userUid: createdEnvironment.userUid, + id: createdEnvironment.id, + name: createdEnvironment.name, + variables: JSON.stringify(createdEnvironment.variables), + isGlobal: createdEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.pubsub.publish( + `user_environment/${userEnvironment.userUid}/created`, + userEnvironment, + ); + return E.right(userEnvironment); + } + + /** + * Update an existing personal or global user environment + * @param id environment id + * @param name environments name + * @param variables environment variables + * @returns an Either of `UserEnvironment` or error + */ + async updateUserEnvironment(id: string, name: string, variables: string) { + const envVariables = stringToJson(variables); + if (E.isLeft(envVariables)) return E.left(envVariables.left); + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id }, + data: { + name: name, + variables: envVariables.right, + }, + }); + + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + // Publish subscription for environment update + await this.pubsub.publish( + `user_environment/${updatedUserEnvironment.id}/updated`, + updatedUserEnvironment, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + } + } + + /** + * Delete an existing personal user environment based on environment id + * @param uid users uid + * @param id environment id + * @returns an Either of deleted `UserEnvironment` or error + */ + async deleteUserEnvironment(uid: string, id: string) { + try { + // check if id is of a global environment if it is, don't delete and error out + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (O.isSome(globalEnvExists)) { + const globalEnv = globalEnvExists.value; + if (globalEnv.id === id) { + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED); + } + } + const deletedEnvironment = await this.prisma.userEnvironment.delete({ + where: { + id: id, + }, + }); + + const deletedUserEnvironment: UserEnvironment = { + userUid: deletedEnvironment.userUid, + id: deletedEnvironment.id, + name: deletedEnvironment.name, + variables: JSON.stringify(deletedEnvironment.variables), + isGlobal: deletedEnvironment.isGlobal, + }; + + // Publish subscription for environment deletion + await this.pubsub.publish( + `user_environment/${deletedUserEnvironment.id}/deleted`, + deletedUserEnvironment, + ); + return E.right(true); + } catch (e) { + return E.left(USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS); + } + } + + /** + * Deletes all existing personal user environments + * @param uid user uid + * @returns a count of environments deleted + */ + async deleteUserEnvironments(uid: string) { + const deletedEnvironments = await this.prisma.userEnvironment.deleteMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + + // Publish subscription for multiple environment deletions + await this.pubsub.publish( + `user_environment/${uid}/deleted_many`, + deletedEnvironments.count, + ); + + return deletedEnvironments.count; + } + + /** + * Removes all existing variables in a users global environment + * @param uid users uid + * @param id environment id + * @returns an `` of environments deleted + */ + async clearGlobalEnvironments(uid: string, id: string) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (O.isNone(globalEnvExists)) + return E.left(USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS); + + const env = globalEnvExists.value; + if (env.id === id) { + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id }, + data: { + variables: [], + }, + }); + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + + // Publish subscription for environment update + await this.pubsub.publish( + `user_environment/${updatedUserEnvironment.id}/updated`, + updatedUserEnvironment, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left(USER_ENVIRONMENT_UPDATE_FAILED); + } + } else return E.left(USER_ENVIRONMENT_IS_NOT_GLOBAL); + } + + // Method to check for existing global environments for a given user uid + private async checkForExistingGlobalEnv(uid: string) { + const globalEnv = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + }); + + if (globalEnv == null) return O.none; + return O.some(globalEnv); + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts new file mode 100644 index 000000000..0d6d31cf4 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts @@ -0,0 +1,29 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { User } from 'src/user/user.model'; +import { UserEnvironment } from './user-environments.model'; +import { UserEnvironmentsService } from './user-environments.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from '../utils'; + +@Resolver(() => User) +export class UserEnvsUserResolver { + constructor(private userEnvironmentsService: UserEnvironmentsService) {} + @ResolveField(() => [UserEnvironment], { + description: 'Returns a list of users personal environments', + }) + async environments(@Parent() user: User): Promise { + return await this.userEnvironmentsService.fetchUserEnvironments(user.uid); + } + + @ResolveField(() => UserEnvironment, { + description: 'Returns the users global environments', + }) + async globalEnvironments( + @Parent() user: User, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.fetchUserGlobalEnvironment(user.uid); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } +} diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 35d2c8139..570313640 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -30,19 +30,6 @@ export const trace = (val: T) => { return val; }; -/** - * String to JSON parser - * @param {str} str The string to parse - * @returns {E.Right | E.Left<"json_invalid">} An Either of the parsed JSON - */ -export function stringToJson(str: string): E.Right | E.Left { - try { - return E.right(JSON.parse(str)); - } catch (err) { - return E.left(JSON_INVALID); - } -} - /** * Similar to `trace` but allows for labels and also an * optional transform function. @@ -123,3 +110,18 @@ export const taskEitherValidateArraySeq = ( TE.getApplicativeTaskValidation(T.ApplicativeSeq, A.getMonoid()), ), ); + +/** + * String to JSON parser + * @param {str} str The string to parse + * @returns {E.Right | E.Left<"json_invalid">} An Either of the parsed JSON + */ +export function stringToJson( + str: string, +): E.Right | E.Left { + try { + return E.right(JSON.parse(str)); + } catch (err) { + return E.left(JSON_INVALID); + } +} diff --git a/packages/hoppscotch-backend/tsconfig.json b/packages/hoppscotch-backend/tsconfig.json index adb614cab..02420b5f7 100644 --- a/packages/hoppscotch-backend/tsconfig.json +++ b/packages/hoppscotch-backend/tsconfig.json @@ -12,10 +12,11 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, + "strict": false, "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": true, } }