Merge pull request #8 from hoppscotch/feat/user-session

feat: introducing current session of rest and gql (backend)
This commit is contained in:
Ankit Sridhar
2023-01-24 17:09:30 +05:30
committed by GitHub
9 changed files with 292 additions and 23 deletions

View File

@@ -79,14 +79,15 @@ model TeamEnvironment {
}
model User {
uid String @id @default(cuid())
displayName String?
email String?
photoURL String?
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
uid String @id @default(cuid())
displayName String?
email String?
photoURL String?
currentRESTSession Json?
currentGQLSession Json?
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
}
model UserSettings {
@@ -105,7 +106,7 @@ model UserHistory {
request Json
responseMetadata Json
isStarred Boolean
executedOn DateTime @default(now()) @db.Timestamp(3)
executedOn DateTime @default(now()) @db.Timestamp(3)
}
enum ReqType {

View File

@@ -26,6 +26,12 @@ export const USER_FB_DOCUMENT_DELETION_FAILED =
*/
export const USER_NOT_FOUND = 'user/not_found' as const;
/**
* User update failure
* (UserService)
*/
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
/**
* User deletion failure
* (UserService)

View File

@@ -16,4 +16,4 @@ export class PrismaService
async onModuleDestroy() {
await this.$disconnect();
}
}
}

View File

@@ -1,3 +1,4 @@
import { User } from 'src/user/user.model';
import { UserSettings } from 'src/user-settings/user-settings.model';
import { UserEnvironment } from '../user-environment/user-environments.model';
import { UserHistory } from '../user-history/user-history.model';
@@ -5,10 +6,11 @@ import { UserHistory } from '../user-history/user-history.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/${string}/${'updated'}`]: User;
[topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings;
[
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
]: UserEnvironment;
[topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings;
[topic: `user_environment/${string}/deleted_many`]: number;
[
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`

View File

@@ -1,27 +1,54 @@
import { ObjectType, ID, Field } from '@nestjs/graphql';
import {
ObjectType,
ID,
Field,
InputType,
registerEnumType,
} from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => ID, {
description: 'Firebase UID of the user',
description: 'UID of the user',
})
uid: string;
@Field({
nullable: true,
description: 'Displayed name of the user (if given)',
description: 'Displayed name of the user',
})
displayName?: string;
@Field({
nullable: true,
description: 'Email of the user (if given)',
description: 'Email of the user',
})
email?: string;
@Field({
nullable: true,
description: 'URL to the profile photo of the user (if given)',
description: 'URL to the profile photo of the user',
})
photoURL?: string;
@Field({
nullable: true,
description: 'Stringified current REST session for logged-in User',
})
currentRESTSession?: string;
@Field({
nullable: true,
description: 'Stringified current GraphQL session for logged-in User',
})
currentGQLSession?: string;
}
export enum SessionType {
REST = 'REST',
GQL = 'GQL',
}
registerEnumType(SessionType, {
name: 'SessionType',
});

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { UserResolver } from './user.resolver';
import { PubSubModule } from 'src/pubsub/pubsub.module';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UserService } from './user.service';
@Module({
imports: [PubSubModule],
providers: [UserResolver],
imports: [PubSubModule, PrismaModule],
providers: [UserResolver, UserService],
exports: [],
})
export class UserModule {}

View File

@@ -1,14 +1,19 @@
import { Resolver, Query } from '@nestjs/graphql';
import { User } from './user.model';
import { Resolver, Query, Mutation, Args, Subscription } from '@nestjs/graphql';
import { SessionType, User } from './user.model';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '../guards/gql-auth.guard';
import { GqlUser } from '../decorators/gql-user.decorator';
import { UserService } from './user.service';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/lib/Either';
import { PubSubService } from 'src/pubsub/pubsub.service';
@Resolver(() => User)
export class UserResolver {
// TODO: remove the eslint-disable line below once dependencies are added to user.service file
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor() {}
constructor(
private readonly userService: UserService,
private readonly pubsub: PubSubService,
) {}
@Query(() => User, {
description:
@@ -27,4 +32,43 @@ export class UserResolver {
me2(@GqlUser() user: User): User {
return user;
}
/* Mutations */
@Mutation(() => User, {
description: 'Update user sessions',
})
@UseGuards(GqlAuthGuard)
async updateUserSessions(
@GqlUser() user: User,
@Args({
name: 'currentSession',
description: 'JSON string of the saved REST/GQL session',
})
currentSession: string,
@Args({
name: 'sessionType',
description: 'Type of the session',
})
sessionType: SessionType,
): Promise<User> {
const updatedUser = await this.userService.updateUserSessions(
user,
currentSession,
sessionType,
);
if (E.isLeft(updatedUser)) throwErr(updatedUser.left);
return updatedUser.right;
}
/* Subscriptions */
@Subscription(() => User, {
description: 'Listen for user updates',
resolve: (value) => value,
})
@UseGuards(GqlAuthGuard)
userUpdated(@GqlUser() user: User) {
return this.pubsub.asyncIterator(`user/${user.uid}/updated`);
}
}

View File

@@ -0,0 +1,108 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { JSON_INVALID } from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { UserService } from './user.service';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
// @ts-ignore
const userService = new UserService(mockPrisma, mockPubSub as any);
const user = {
uid: '123',
displayName: 'John Doe',
email: 'test@hoppscotch.io',
photoURL: 'https://example.com/avatar.png',
currentRESTSession: JSON.stringify({}),
currentGQLSession: JSON.stringify({}),
};
beforeEach(() => {
mockReset(mockPrisma);
mockPubSub.publish.mockClear();
});
describe('UserService', () => {
describe('updateUserSessions', () => {
test('Should resolve right and update users GQL session', async () => {
const sessionData = user.currentGQLSession;
mockPrisma.user.update.mockResolvedValue({
...user,
currentGQLSession: JSON.parse(sessionData),
currentRESTSession: null,
});
const result = await userService.updateUserSessions(
user,
sessionData,
'GQL',
);
expect(result).toEqualRight({
...user,
currentGQLSession: sessionData,
currentRESTSession: null,
});
});
test('Should resolve right and update users REST session', async () => {
const sessionData = user.currentGQLSession;
mockPrisma.user.update.mockResolvedValue({
...user,
currentGQLSession: null,
currentRESTSession: JSON.parse(sessionData),
});
const result = await userService.updateUserSessions(
user,
sessionData,
'REST',
);
expect(result).toEqualRight({
...user,
currentGQLSession: null,
currentRESTSession: sessionData,
});
});
test('Should reject left and update user for invalid GQL session', async () => {
const sessionData = 'invalid json';
const result = await userService.updateUserSessions(
user,
sessionData,
'GQL',
);
expect(result).toEqualLeft(JSON_INVALID);
});
test('Should reject left and update user for invalid REST session', async () => {
const sessionData = 'invalid json';
const result = await userService.updateUserSessions(
user,
sessionData,
'REST',
);
expect(result).toEqualLeft(JSON_INVALID);
});
test('Should publish pubsub message on user update sessions', async () => {
mockPrisma.user.update.mockResolvedValue({
...user,
currentGQLSession: JSON.parse(user.currentGQLSession),
currentRESTSession: JSON.parse(user.currentRESTSession),
});
await userService.updateUserSessions(user, user.currentGQLSession, 'GQL');
expect(mockPubSub.publish).toHaveBeenCalledTimes(1);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`user/${user.uid}/updated`,
user,
);
});
});
});

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SessionType, User } from './user.model';
import * as E from 'fp-ts/lib/Either';
import { USER_UPDATE_FAILED } from 'src/errors';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { stringToJson } from 'src/utils';
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly pubsub: PubSubService,
) {}
/**
* Update a user's sessions
* @param user User object
* @param currentRESTSession user's current REST session
* @param currentGQLSession user's current GQL session
* @returns a Either of User or error
*/
async updateUserSessions(
user: User,
currentSession: string,
sessionType: string,
): Promise<E.Right<User> | E.Left<string>> {
const validatedSession = await this.validateSession(currentSession);
if (E.isLeft(validatedSession)) return E.left(validatedSession.left);
try {
const sessionObj = {};
switch (sessionType) {
case SessionType.GQL:
sessionObj['currentGQLSession'] = validatedSession.right;
break;
case SessionType.REST:
sessionObj['currentRESTSession'] = validatedSession.right;
break;
default:
return E.left(USER_UPDATE_FAILED);
}
const dbUpdatedUser = await this.prisma.user.update({
where: { uid: user.uid },
data: sessionObj,
});
const updatedUser: User = {
...dbUpdatedUser,
currentGQLSession: dbUpdatedUser.currentGQLSession
? JSON.stringify(dbUpdatedUser.currentGQLSession)
: null,
currentRESTSession: dbUpdatedUser.currentRESTSession
? JSON.stringify(dbUpdatedUser.currentRESTSession)
: null,
};
// Publish subscription for user updates
await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser);
return E.right(updatedUser);
} catch (e) {
return E.left(USER_UPDATE_FAILED);
}
}
/**
* Validate and parse currentRESTSession and currentGQLSession
* @param sessionData string of the session
* @returns a Either of JSON object or error
*/
async validateSession(sessionData: string) {
const jsonSession = stringToJson(sessionData);
if (E.isLeft(jsonSession)) return E.left(jsonSession.left);
return E.right(jsonSession.right);
}
}