Merge pull request #2 from hoppscotch/chore/backend-integration
chore: backend integration for existing modules and docker fix
This commit is contained in:
1
packages/hoppscotch-backend/.dockerignore
Normal file
1
packages/hoppscotch-backend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
./node_modules
|
||||
@@ -2,7 +2,7 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir : __dirname,
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
}
|
||||
|
||||
23
packages/hoppscotch-backend/Dockerfile
Normal file
23
packages/hoppscotch-backend/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM node:lts
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# # Install pnpm
|
||||
RUN npm i -g pnpm
|
||||
|
||||
# Prisma bits
|
||||
COPY prisma ./
|
||||
RUN pnpx prisma generate
|
||||
|
||||
# # NPM package install
|
||||
COPY . .
|
||||
RUN pnpm i
|
||||
|
||||
EXPOSE 3170
|
||||
EXPOSE 9229
|
||||
|
||||
ENV APP_PORT=${PORT}
|
||||
ENV DB_URL=${DATABASE_URL}
|
||||
ENV PRODUCTION=true
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
||||
26
packages/hoppscotch-backend/docker-compose.yml
Normal file
26
packages/hoppscotch-backend/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.0'
|
||||
services:
|
||||
local:
|
||||
build: .
|
||||
environment:
|
||||
- PRODUCTION=false
|
||||
- DATABASE_URL=postgresql://postgres:testpass@dev-db:5432/hoppscotch?connect_timeout=300
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- .:/usr/src/app
|
||||
- /usr/src/app/node_modules/
|
||||
depends_on:
|
||||
- dev-db
|
||||
ports:
|
||||
- "3170:3000"
|
||||
- "9229:9229"
|
||||
|
||||
dev-db:
|
||||
image: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: hoppscotch
|
||||
|
||||
|
||||
@@ -21,9 +21,21 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/apollo": "^10.1.6",
|
||||
"@nestjs/common": "^9.2.1",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/graphql": "^10.1.6",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@prisma/client": "^4.7.1",
|
||||
"apollo-server-express": "^3.11.1",
|
||||
"apollo-server-plugin-base": "^3.7.1",
|
||||
"fp-ts": "^2.13.1",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-query-complexity": "^0.12.0",
|
||||
"graphql-redis-subscriptions": "^2.5.0",
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"ioredis": "^5.2.4",
|
||||
"prisma": "^4.7.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.6.0"
|
||||
|
||||
92
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
92
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,92 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x"]
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
members TeamMember[]
|
||||
TeamInvitation TeamInvitation[]
|
||||
TeamCollection TeamCollection[]
|
||||
TeamRequest TeamRequest[]
|
||||
TeamEnvironment TeamEnvironment[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid()) // Membership ID
|
||||
role TeamMemberRole
|
||||
userUid String
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamID, userUid])
|
||||
}
|
||||
|
||||
model TeamInvitation {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
creatorUid String
|
||||
inviteeEmail String
|
||||
inviteeRole TeamMemberRole
|
||||
|
||||
@@unique([teamID, inviteeEmail])
|
||||
@@index([teamID])
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
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)
|
||||
title String
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
id String @id
|
||||
request Json
|
||||
creatorUid String?
|
||||
createdOn DateTime @default(now())
|
||||
|
||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
}
|
||||
|
||||
model TeamEnvironment {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
variables Json
|
||||
}
|
||||
|
||||
model User {
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String?
|
||||
photoURL String?
|
||||
}
|
||||
|
||||
enum TeamMemberRole {
|
||||
OWNER
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||
playground: process.env.PRODUCTION !== 'true',
|
||||
debug: process.env.PRODUCTION !== 'true',
|
||||
autoSchemaFile: true,
|
||||
installSubscriptionHandlers: true,
|
||||
subscriptions: {
|
||||
'subscriptions-transport-ws': {
|
||||
path: '/graphql',
|
||||
onConnect: (connectionParams: any) => {
|
||||
return {
|
||||
reqHeaders: Object.fromEntries(
|
||||
Object.entries(connectionParams).map(([k, v]) => [
|
||||
k.toLowerCase(),
|
||||
v,
|
||||
]),
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
context: async ({ req, connection }) => {
|
||||
if (req) {
|
||||
return { reqHeaders: req.headers };
|
||||
} else {
|
||||
return {
|
||||
// Lowercase the keys
|
||||
reqHeaders: Object.fromEntries(
|
||||
Object.entries(connection.context).map(([k, v]) => [
|
||||
k.toLowerCase(),
|
||||
v,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
driver: ApolloDriver,
|
||||
}),
|
||||
UserModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { User } from '../user/user.model';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const GqlUser = createParamDecorator<any, any, User>(
|
||||
(_data: any, context: ExecutionContext) => {
|
||||
const { user } = GqlExecutionContext.create(context).getContext<{
|
||||
user: User;
|
||||
}>();
|
||||
if (!user)
|
||||
throw new Error(
|
||||
'@GqlUser decorator use with null user. Make sure the resolve has the @GqlAuthGuard present.',
|
||||
);
|
||||
|
||||
return user;
|
||||
},
|
||||
);
|
||||
211
packages/hoppscotch-backend/src/errors.ts
Normal file
211
packages/hoppscotch-backend/src/errors.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
export const INVALID_EMAIL = 'invalid/email' as const;
|
||||
|
||||
export const EMAIL_FAILED = 'email/failed' as const;
|
||||
|
||||
/**
|
||||
* Token Authorization failed (Check 'Authorization' Header)
|
||||
* (GqlAuthGuard)
|
||||
*/
|
||||
export const AUTH_FAIL = 'auth/fail';
|
||||
|
||||
/**
|
||||
* Tried to delete an user data document from fb firestore but failed.
|
||||
* (FirebaseService)
|
||||
*/
|
||||
export const USER_FB_DOCUMENT_DELETION_FAILED =
|
||||
'fb/firebase_document_deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* Tried to do an action on a user where user is not found
|
||||
*/
|
||||
export const USER_NOT_FOUND = 'user/not_found' as const;
|
||||
|
||||
/**
|
||||
* User deletion failure
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_DELETION_FAILED = 'user/deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* User deletion failure error due to user being a team owner
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_IS_OWNER = 'user/is_owner' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform action on a team which they are not a member of
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform action on a team that doesn't accept their member role level
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
|
||||
|
||||
/**
|
||||
* Team name validation failure
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_NAME_INVALID = 'team/name_invalid';
|
||||
|
||||
/**
|
||||
* Couldn't find the sync data from the user
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata';
|
||||
|
||||
/**
|
||||
* There was a problem resolving the firebase collection path
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail';
|
||||
|
||||
/**
|
||||
* Tried to update the team to a state it doesn't have any owners
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner';
|
||||
|
||||
/**
|
||||
* Invalid or non-existent Team ID
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_INVALID_ID = 'team/invalid_id' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent collection id
|
||||
* (GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const;
|
||||
|
||||
/**
|
||||
* Invalid team id or user id
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user';
|
||||
|
||||
/**
|
||||
* The provided title for the team collection is short (less than 3 characters)
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title';
|
||||
|
||||
/**
|
||||
* The JSON used is not valid
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
|
||||
|
||||
/**
|
||||
* Tried to perform action on a request that doesn't accept their member role level
|
||||
* (GqlRequestTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
|
||||
|
||||
/**
|
||||
* Tried to operate on a request which does not exist
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent collection id
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
||||
'team_req/invalid_target_id' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform action on a request when the user is not even member of the team
|
||||
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
|
||||
|
||||
export const TEAM_INVITE_MEMBER_HAS_INVITE =
|
||||
'team_invite/member_has_invite' as const;
|
||||
|
||||
export const TEAM_INVITE_NO_INVITE_FOUND =
|
||||
'team_invite/no_invite_found' as const;
|
||||
|
||||
export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const;
|
||||
|
||||
export const TEAM_INVITE_EMAIL_DO_NOT_MATCH =
|
||||
'team_invite/email_do_not_match' as const;
|
||||
|
||||
export const TEAM_INVITE_NOT_VALID_VIEWER =
|
||||
'team_invite/not_valid_viewer' as const;
|
||||
|
||||
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
||||
|
||||
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent TEAM ENVIRONMMENT ID
|
||||
* (TeamEnvironmentsService)
|
||||
*/
|
||||
export const TEAM_ENVIRONMMENT_NOT_FOUND =
|
||||
'team_environment/not_found' as const;
|
||||
|
||||
/**
|
||||
* The user is not a member of the team of the given environment
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER =
|
||||
'team_environment/not_team_member' as const;
|
||||
|
||||
/*
|
||||
|
||||
|------------------------------------|
|
||||
|Server errors that are actually bugs|
|
||||
|------------------------------------|
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied)
|
||||
* (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present)
|
||||
* (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id';
|
||||
|
||||
/**
|
||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role';
|
||||
|
||||
/**
|
||||
* Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists)
|
||||
* (GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id';
|
||||
|
||||
/**
|
||||
* Couldn't find 'requestID' param to the attached GQL operation. (Check if exists)
|
||||
* (GqlRequestTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id';
|
||||
|
||||
export const BUG_TEAM_INVITE_NO_INVITE_ID =
|
||||
'bug/team_invite/no_invite_id' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES =
|
||||
'bug/team_env/guard_no_require_roles' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find 'id' param to the operation. (Check if it is applied)
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const BUG_TEAM_ENV_GUARD_NO_ENV_ID =
|
||||
'bug/team_env/guard_no_env_id' as const;
|
||||
42
packages/hoppscotch-backend/src/guards/gql-auth.guard.ts
Normal file
42
packages/hoppscotch-backend/src/guards/gql-auth.guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CanActivate, Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { User } from '../user/user.model';
|
||||
import { IncomingHttpHeaders } from 'http2';
|
||||
import { AUTH_FAIL } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class GqlAuthGuard implements CanActivate {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
constructor() {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
const ctx = GqlExecutionContext.create(context).getContext<{
|
||||
reqHeaders: IncomingHttpHeaders;
|
||||
user: User | null;
|
||||
}>();
|
||||
|
||||
if (
|
||||
ctx.reqHeaders.authorization &&
|
||||
ctx.reqHeaders.authorization.startsWith('Bearer ')
|
||||
) {
|
||||
const idToken = ctx.reqHeaders.authorization.split(' ')[1];
|
||||
|
||||
const authUser: User = {
|
||||
uid: 'aabb22ccdd',
|
||||
displayName: 'exampleUser',
|
||||
photoURL: 'http://example.com/avatar',
|
||||
email: 'me@example.com',
|
||||
};
|
||||
|
||||
ctx.user = authUser;
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(AUTH_FAIL);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,33 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||
console.log(`Port: ${process.env.PORT}`);
|
||||
console.log(`Database: ${process.env.DATABASE_URL}`);
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
|
||||
// Increase fil upload limit to 50MB
|
||||
app.use(
|
||||
json({
|
||||
limit: '100mb',
|
||||
}),
|
||||
);
|
||||
|
||||
if (process.env.PRODUCTION === 'false') {
|
||||
console.log('Enabling CORS with development settings');
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
});
|
||||
} else {
|
||||
console.log('Enabling CORS with production settings');
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
});
|
||||
}
|
||||
await app.listen(process.env.PORT || 3170);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { GraphQLSchemaHost } from '@nestjs/graphql';
|
||||
import {
|
||||
ApolloServerPlugin,
|
||||
GraphQLRequestListener,
|
||||
} from 'apollo-server-plugin-base';
|
||||
import { Plugin } from '@nestjs/apollo';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import {
|
||||
fieldExtensionsEstimator,
|
||||
getComplexity,
|
||||
simpleEstimator,
|
||||
} from 'graphql-query-complexity';
|
||||
|
||||
const COMPLEXITY_LIMIT = 50;
|
||||
|
||||
@Plugin()
|
||||
export class GQLComplexityPlugin implements ApolloServerPlugin {
|
||||
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
|
||||
|
||||
async requestDidStart(): Promise<GraphQLRequestListener> {
|
||||
const { schema } = this.gqlSchemaHost;
|
||||
|
||||
return {
|
||||
async didResolveOperation({ request, document }) {
|
||||
const complexity = getComplexity({
|
||||
schema,
|
||||
operationName: request.operationName,
|
||||
query: document,
|
||||
variables: request.variables,
|
||||
estimators: [
|
||||
fieldExtensionsEstimator(),
|
||||
simpleEstimator({ defaultComplexity: 1 }),
|
||||
],
|
||||
});
|
||||
if (complexity > COMPLEXITY_LIMIT) {
|
||||
throw new GraphQLError(
|
||||
`Query is too complex: ${complexity}. Maximum allowed complexity: ${COMPLEXITY_LIMIT}`,
|
||||
);
|
||||
}
|
||||
console.log('Query Complexity:', complexity);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
8
packages/hoppscotch-backend/src/prisma/prisma.module.ts
Normal file
8
packages/hoppscotch-backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common/decorators';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
19
packages/hoppscotch-backend/src/prisma/prisma.service.ts
Normal file
19
packages/hoppscotch-backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client/scripts/default-index';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
8
packages/hoppscotch-backend/src/pubsub/pubsub.module.ts
Normal file
8
packages/hoppscotch-backend/src/pubsub/pubsub.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PubSubService } from './pubsub.service';
|
||||
|
||||
@Module({
|
||||
providers: [PubSubService],
|
||||
exports: [PubSubService],
|
||||
})
|
||||
export class PubSubModule {}
|
||||
76
packages/hoppscotch-backend/src/pubsub/pubsub.service.ts
Normal file
76
packages/hoppscotch-backend/src/pubsub/pubsub.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { default as Redis, RedisOptions } from 'ioredis';
|
||||
|
||||
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||
import { PubSub as LocalPubSub } from 'graphql-subscriptions';
|
||||
|
||||
/**
|
||||
* RedisPubSub uses JSON parsing for back and forth conversion, which loses Date objects, hence this reviver brings them back
|
||||
* This function implementation should function like the JSON.parse reviver function
|
||||
* @param key The key being parsed
|
||||
* @param value The value being parsed
|
||||
* @returns The updated value for the key
|
||||
*/
|
||||
const dateReviver = (key: string, value: unknown) => {
|
||||
const isISO8601Z =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/;
|
||||
|
||||
if (typeof value === 'string' && isISO8601Z.test(value)) {
|
||||
const tempDateNumber = Date.parse(value);
|
||||
|
||||
if (!isNaN(tempDateNumber)) {
|
||||
return new Date(tempDateNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/*
|
||||
* Figure out which PubSub to use (simple/local for dev and Redis for production)
|
||||
* and expose it
|
||||
*/
|
||||
|
||||
@Injectable()
|
||||
export class PubSubService implements OnModuleInit {
|
||||
private pubsub: RedisPubSub | LocalPubSub;
|
||||
|
||||
onModuleInit() {
|
||||
if (process.env.PRODUCTION === 'false') {
|
||||
console.log('Initialize PubSub in development mode');
|
||||
|
||||
this.pubsub = new LocalPubSub();
|
||||
} else {
|
||||
console.log('Initialize PubSub in production mode');
|
||||
console.log(
|
||||
`REDIS_IP: ${process.env.REDIS_IP}, REDIS_PORT: ${process.env.REDIS_PORT}`,
|
||||
);
|
||||
|
||||
const redisOptions: RedisOptions = {
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
host: process.env.REDIS_IP,
|
||||
retryStrategy: (times: number) => {
|
||||
return Math.min(times * 50, 2000);
|
||||
},
|
||||
};
|
||||
|
||||
this.pubsub = new RedisPubSub({
|
||||
publisher: new Redis(redisOptions),
|
||||
subscriber: new Redis(redisOptions),
|
||||
reviver: dateReviver,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
asyncIterator<T>(
|
||||
topic: string | string[],
|
||||
options?: unknown,
|
||||
): AsyncIterator<T> {
|
||||
return this.pubsub.asyncIterator(topic, options);
|
||||
}
|
||||
|
||||
async publish(topic: string, payload: any) {
|
||||
await this.pubsub.publish(topic, payload);
|
||||
}
|
||||
}
|
||||
27
packages/hoppscotch-backend/src/user/user.model.ts
Normal file
27
packages/hoppscotch-backend/src/user/user.model.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ObjectType, ID, Field } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class User {
|
||||
@Field(() => ID, {
|
||||
description: 'Firebase UID of the user',
|
||||
})
|
||||
uid: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Displayed name of the user (if given)',
|
||||
})
|
||||
displayName?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'Email of the user (if given)',
|
||||
})
|
||||
email?: string;
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'URL to the profile photo of the user (if given)',
|
||||
})
|
||||
photoURL?: string;
|
||||
}
|
||||
10
packages/hoppscotch-backend/src/user/user.module.ts
Normal file
10
packages/hoppscotch-backend/src/user/user.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserResolver } from './user.resolver';
|
||||
import { PubSubModule } from 'src/pubsub/pubsub.module';
|
||||
|
||||
@Module({
|
||||
imports: [PubSubModule],
|
||||
providers: [UserResolver],
|
||||
exports: [],
|
||||
})
|
||||
export class UserModule {}
|
||||
30
packages/hoppscotch-backend/src/user/user.resolver.ts
Normal file
30
packages/hoppscotch-backend/src/user/user.resolver.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Resolver, Query } from '@nestjs/graphql';
|
||||
import { User } from './user.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
||||
|
||||
@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() {}
|
||||
|
||||
@Query(() => User, {
|
||||
description:
|
||||
"Gives details of the user executing this query (pass Authorization 'Bearer' header)",
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
me(@GqlUser() user: User): User {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Query(() => User, {
|
||||
description:
|
||||
"Gives details of the user executing this query (pass Authorization 'Bearer' header)",
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
me2(@GqlUser() user: User): User {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
110
packages/hoppscotch-backend/src/utils.ts
Normal file
110
packages/hoppscotch-backend/src/utils.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { pipe } from 'fp-ts/lib/function';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import * as T from 'fp-ts/Task';
|
||||
import { User } from './user/user.model';
|
||||
import * as A from 'fp-ts/Array';
|
||||
|
||||
/**
|
||||
* A workaround to throw an exception in an expression.
|
||||
* JS throw keyword creates a statement not an expression.
|
||||
* This function allows throw to be used as an expression
|
||||
* @param errMessage Message present in the error message
|
||||
*/
|
||||
export function throwErr(errMessage: string): never {
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the given value to log and returns the same value.
|
||||
* Used for debugging functional pipelines.
|
||||
* @param val The value to print
|
||||
* @returns `val` itself
|
||||
*/
|
||||
export const trace = <T>(val: T) => {
|
||||
console.log(val);
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similar to `trace` but allows for labels and also an
|
||||
* optional transform function.
|
||||
* @param name The label to given to the trace log (log outputs like this "<name>: <value>")
|
||||
* @param transform An optional function to transform the log output value (useful for checking specific aspects or transforms (duh))
|
||||
* @returns A function which takes a value, and is traced.
|
||||
*/
|
||||
export const namedTrace =
|
||||
<T>(name: string, transform?: (val: T) => unknown) =>
|
||||
(val: T) => {
|
||||
console.log(`${name}:`, transform ? transform(val) : val);
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the list of required roles annotated on a GQL Operation
|
||||
* @param reflector NestJS Reflector instance
|
||||
* @param context NestJS Execution Context
|
||||
* @returns An Option which contains the defined roles
|
||||
*/
|
||||
// export const getAnnotatedRequiredRoles = (
|
||||
// reflector: Reflector,
|
||||
// context: ExecutionContext,
|
||||
// ) =>
|
||||
// pipe(
|
||||
// reflector.get<TeamMemberRole[]>('requiresTeamRole', context.getHandler()),
|
||||
// O.fromNullable,
|
||||
// );
|
||||
|
||||
/**
|
||||
* Gets the user from the NestJS GQL Execution Context.
|
||||
* Usually used within guards.
|
||||
* @param ctx The Execution Context to use to get it
|
||||
* @returns An Option of the user
|
||||
*/
|
||||
export const getUserFromGQLContext = (ctx: ExecutionContext) =>
|
||||
pipe(
|
||||
ctx,
|
||||
GqlExecutionContext.create,
|
||||
(ctx) => ctx.getContext<{ user?: User }>(),
|
||||
({ user }) => user,
|
||||
O.fromNullable,
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets a GQL Argument in the defined operation.
|
||||
* Usually used in guards.
|
||||
* @param argName The name of the argument to get
|
||||
* @param ctx The NestJS Execution Context to use to get it.
|
||||
* @returns The argument value typed as `unknown`
|
||||
*/
|
||||
export const getGqlArg = <ArgName extends string>(
|
||||
argName: ArgName,
|
||||
ctx: ExecutionContext,
|
||||
) =>
|
||||
pipe(
|
||||
ctx,
|
||||
GqlExecutionContext.create,
|
||||
(ctx) => ctx.getArgs<object>(),
|
||||
// We are not sure if this thing will even exist
|
||||
// We pass that worry to the caller
|
||||
(args) => args[argName as any] as unknown,
|
||||
);
|
||||
|
||||
/**
|
||||
* Sequences an array of TaskEither values while maintaining an array of all the error values
|
||||
* @param arr Array of TaskEithers
|
||||
* @returns A TaskEither saying all the errors possible on the left or all the success values on the right
|
||||
*/
|
||||
export const taskEitherValidateArraySeq = <A, B>(
|
||||
arr: TE.TaskEither<A, B>[],
|
||||
): TE.TaskEither<A[], B[]> =>
|
||||
pipe(
|
||||
arr,
|
||||
A.map(TE.mapLeft(A.of)),
|
||||
A.sequence(
|
||||
TE.getApplicativeTaskValidation(T.ApplicativeSeq, A.getMonoid<A>()),
|
||||
),
|
||||
);
|
||||
771
pnpm-lock.yaml
generated
771
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user