Merge pull request #8 from hoppscotch/feat/user-session
feat: introducing current session of rest and gql (backend)
This commit is contained in:
@@ -83,8 +83,9 @@ model User {
|
|||||||
displayName String?
|
displayName String?
|
||||||
email String?
|
email String?
|
||||||
photoURL String?
|
photoURL String?
|
||||||
|
currentRESTSession Json?
|
||||||
|
currentGQLSession Json?
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
|
|
||||||
UserHistory UserHistory[]
|
UserHistory UserHistory[]
|
||||||
UserEnvironments UserEnvironment[]
|
UserEnvironments UserEnvironment[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export const USER_FB_DOCUMENT_DELETION_FAILED =
|
|||||||
*/
|
*/
|
||||||
export const USER_NOT_FOUND = 'user/not_found' as const;
|
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
|
* User deletion failure
|
||||||
* (UserService)
|
* (UserService)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { User } from 'src/user/user.model';
|
||||||
import { UserSettings } from 'src/user-settings/user-settings.model';
|
import { UserSettings } from 'src/user-settings/user-settings.model';
|
||||||
import { UserEnvironment } from '../user-environment/user-environments.model';
|
import { UserEnvironment } from '../user-environment/user-environments.model';
|
||||||
import { UserHistory } from '../user-history/user-history.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.
|
// 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.
|
// For every module that publishes a subscription add its type def and the possible subscription type.
|
||||||
export type TopicDef = {
|
export type TopicDef = {
|
||||||
|
[topic: `user/${string}/${'updated'}`]: User;
|
||||||
|
[topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings;
|
||||||
[
|
[
|
||||||
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
||||||
]: UserEnvironment;
|
]: UserEnvironment;
|
||||||
[topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings;
|
|
||||||
[topic: `user_environment/${string}/deleted_many`]: number;
|
[topic: `user_environment/${string}/deleted_many`]: number;
|
||||||
[
|
[
|
||||||
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
|
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
|
||||||
|
|||||||
@@ -1,27 +1,54 @@
|
|||||||
import { ObjectType, ID, Field } from '@nestjs/graphql';
|
import {
|
||||||
|
ObjectType,
|
||||||
|
ID,
|
||||||
|
Field,
|
||||||
|
InputType,
|
||||||
|
registerEnumType,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class User {
|
export class User {
|
||||||
@Field(() => ID, {
|
@Field(() => ID, {
|
||||||
description: 'Firebase UID of the user',
|
description: 'UID of the user',
|
||||||
})
|
})
|
||||||
uid: string;
|
uid: string;
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Displayed name of the user (if given)',
|
description: 'Displayed name of the user',
|
||||||
})
|
})
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Email of the user (if given)',
|
description: 'Email of the user',
|
||||||
})
|
})
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
@Field({
|
@Field({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'URL to the profile photo of the user (if given)',
|
description: 'URL to the profile photo of the user',
|
||||||
})
|
})
|
||||||
photoURL?: string;
|
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',
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UserResolver } from './user.resolver';
|
import { UserResolver } from './user.resolver';
|
||||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
||||||
|
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PubSubModule],
|
imports: [PubSubModule, PrismaModule],
|
||||||
providers: [UserResolver],
|
providers: [UserResolver, UserService],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Resolver, Query } from '@nestjs/graphql';
|
import { Resolver, Query, Mutation, Args, Subscription } from '@nestjs/graphql';
|
||||||
import { User } from './user.model';
|
import { SessionType, User } from './user.model';
|
||||||
import { UseGuards } from '@nestjs/common';
|
import { UseGuards } from '@nestjs/common';
|
||||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
||||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
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)
|
@Resolver(() => User)
|
||||||
export class UserResolver {
|
export class UserResolver {
|
||||||
// TODO: remove the eslint-disable line below once dependencies are added to user.service file
|
constructor(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
private readonly userService: UserService,
|
||||||
constructor() {}
|
private readonly pubsub: PubSubService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Query(() => User, {
|
@Query(() => User, {
|
||||||
description:
|
description:
|
||||||
@@ -27,4 +32,43 @@ export class UserResolver {
|
|||||||
me2(@GqlUser() user: User): User {
|
me2(@GqlUser() user: User): User {
|
||||||
return 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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
packages/hoppscotch-backend/src/user/user.service.spec.ts
Normal file
108
packages/hoppscotch-backend/src/user/user.service.spec.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/hoppscotch-backend/src/user/user.service.ts
Normal file
79
packages/hoppscotch-backend/src/user/user.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user