├── .husky ├── pre-commit └── pre-push ├── .prettierignore ├── src ├── common │ ├── database │ │ └── migrations │ │ │ └── README.md │ ├── helper │ │ ├── env.helper.ts │ │ └── env.validation.ts │ ├── util │ │ ├── util.module.ts │ │ └── util.service.ts │ ├── decorators │ │ ├── typeorm.decorator.ts │ │ ├── user.decorator.ts │ │ ├── repository-interceptor.decorator.ts │ │ ├── auth-guard.decorator.ts │ │ ├── query-guard.decorator.ts │ │ └── option.decorator.ts │ ├── exceptions │ │ ├── exception-silencer.filter.ts │ │ ├── exception.constant.ts │ │ ├── index.ts │ │ ├── exception.util.ts │ │ ├── exception.plugin.ts │ │ ├── exception.factory.ts │ │ └── exception.format.ts │ ├── interceptors │ │ ├── timeout.interceptor.ts │ │ ├── logging.interceptor.ts │ │ └── repository.interceptor.ts │ ├── guards │ │ ├── graphql-refresh.guard.ts │ │ ├── graphql-signin.guard.ts │ │ ├── graphql-passport-auth.guard.ts │ │ └── graphql-query-permission.guard.ts │ ├── config │ │ ├── ormconfig.service.ts │ │ ├── ormconfig.ts │ │ └── graphql-config.service.ts │ ├── graphql │ │ ├── custom.input.ts │ │ ├── types.ts │ │ ├── utils │ │ │ ├── types.ts │ │ │ └── processWhere.ts │ │ └── customExtended.ts │ ├── modules │ │ └── typeorm.module.ts │ ├── factory │ │ └── mockFactory.ts │ └── format │ │ └── graphql-error.format.ts ├── user │ ├── user.repository.ts │ ├── user.module.ts │ ├── user.service.ts │ ├── inputs │ │ └── user.input.ts │ ├── entities │ │ └── user.entity.ts │ ├── user.resolver.ts │ ├── user.service.spec.ts │ ├── user.resolver.spec.ts │ └── user.module.integration.spec.ts ├── auth │ ├── models │ │ ├── auth.model.ts │ │ └── access-token.payload.ts │ ├── inputs │ │ └── auth.input.ts │ ├── strategies │ │ ├── local.strategy.ts │ │ ├── jwt.strategy.ts │ │ └── jwt-refresh.strategy.ts │ ├── auth.module.ts │ ├── auth.resolver.ts │ └── auth.service.ts ├── cache │ ├── custom-cache.decorator.ts │ ├── custom-cache.module.ts │ ├── custom-cache.interceptor.ts │ └── custom-cache.service.ts ├── upload │ ├── upload.module.ts │ ├── upload.resolver.ts │ ├── upload.module.integration.spec.ts │ ├── upload.resolver.spec.ts │ ├── upload.service.ts │ └── upload.service.spec.ts ├── health │ ├── health.module.ts │ ├── indicator │ │ └── ping.indicator.ts │ └── health.controller.ts ├── app.module.ts ├── main.ts └── graphql-schema.gql ├── tsconfig.build.json ├── .example.env ├── nest-cli.json ├── .dockerignore ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── generator ├── templates │ ├── repository.hbs │ ├── module.hbs │ ├── input.hbs │ ├── entity.hbs │ ├── service.hbs │ ├── resolver.hbs │ ├── service.spec.hbs │ ├── resolver.spec.hbs │ └── module.integration.spec.hbs └── plopfile.mjs ├── Dockerfile ├── .prettierrc ├── additional.d.ts ├── .gitignore ├── docker-compose.yml ├── docker-compose.e2e.yml ├── tsconfig.json ├── LICENSE ├── eslint.config.mjs ├── .github └── workflows │ └── pull-request-check.yml ├── process-where.md ├── graphql-status-code.md ├── package.json └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.hbs 2 | graphql-schema.gql -------------------------------------------------------------------------------- /src/common/database/migrations/README.md: -------------------------------------------------------------------------------- 1 | # Migration files should be located here 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | DB_HOST = 2 | DB_PORT = 3 | DB_USER = 4 | DB_PASSWORD = 5 | DB_NAME = 6 | PORT = 7 | JWT_PRIVATE_KEY = 8 | JWT_PUBLIC_KEY = 9 | JWT_REFRESH_TOKEN_PRIVATE_KEY = -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "builder": "swc", 6 | "typeCheck": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | BRANCH="$(git rev-parse --abbrev-ref HEAD)" 2 | PROTECTED_BRANCHES="^(main)" 3 | 4 | if [[ "$BRANCH" =~ $PROTECTED_BRANCHES ]]; then 5 | echo "You can't commit directly to main branch" 6 | exit 1 7 | fi 8 | 9 | exit 0 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore all files in the node_modules directory 2 | node_modules/ 3 | 4 | # Ignore the .git directory and all files in it 5 | .git/ 6 | 7 | # Ignore all files in the dist directory 8 | dist/ 9 | 10 | # Ignore all files with the .log or .md extension 11 | *.log 12 | *.md 13 | -------------------------------------------------------------------------------- /src/common/helper/env.helper.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | export function getEnvPath(dest: string): string { 4 | const env: string | undefined = process.env.NODE_ENV; 5 | const filename: string = env ? `.${env}.env` : '.development.env'; 6 | return resolve(`${dest}/${filename}`); 7 | } 8 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "moduleDirectories": ["/../", "node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /src/common/util/util.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import { UtilService } from './util.service'; 5 | 6 | @Module({ 7 | imports: [ConfigModule], 8 | providers: [UtilService], 9 | exports: [UtilService], 10 | }) 11 | export class UtilModule {} 12 | -------------------------------------------------------------------------------- /src/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedRepository } from 'src/common/graphql/customExtended'; 2 | 3 | import { CustomRepository } from '../common/decorators/typeorm.decorator'; 4 | import { User } from './entities/user.entity'; 5 | 6 | @CustomRepository(User) 7 | export class UserRepository extends ExtendedRepository {} 8 | -------------------------------------------------------------------------------- /src/auth/models/auth.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | import { AccessTokenPayload } from './access-token.payload'; 4 | 5 | @ObjectType() 6 | export class JwtWithUser { 7 | @Field(() => String) 8 | jwt: string; 9 | 10 | @Field(() => AccessTokenPayload) 11 | user: AccessTokenPayload; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/decorators/typeorm.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const TYPEORM_CUSTOM_REPOSITORY = Symbol('TYPEORM_CUSTOM_REPOSITORY'); 4 | 5 | export function CustomRepository( 6 | entity: new (...args: unknown[]) => T, 7 | ): ClassDecorator { 8 | return SetMetadata(TYPEORM_CUSTOM_REPOSITORY, entity); 9 | } 10 | -------------------------------------------------------------------------------- /src/common/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, createParamDecorator } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (_: unknown, context: ExecutionContext) => { 6 | const ctx = GqlExecutionContext.create(context); 7 | return ctx.getContext().req.user; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /src/cache/custom-cache.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const CUSTOM_CACHE = Symbol('CUSTOM_CACHE'); 4 | export interface CustomCacheOptions { 5 | key?: string; 6 | 7 | ttl?: number; 8 | 9 | logger?: (...args: unknown[]) => unknown; 10 | } 11 | 12 | export const CustomCache = (options: CustomCacheOptions = {}) => 13 | SetMetadata(CUSTOM_CACHE, options); 14 | -------------------------------------------------------------------------------- /src/common/exceptions/exception-silencer.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { GqlArgumentsHost } from '@nestjs/graphql'; 3 | 4 | @Catch() 5 | export class GraphQLExceptionSilencer implements ExceptionFilter { 6 | catch(exception: unknown, host: ArgumentsHost) { 7 | GqlArgumentsHost.create(host); 8 | 9 | throw exception; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /generator/templates/repository.hbs: -------------------------------------------------------------------------------- 1 | import { CustomRepository } from '../common/decorators/typeorm.decorator' 2 | import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity' 3 | import { ExtendedRepository } from 'src/common/graphql/customExtended' 4 | 5 | @CustomRepository({{pascalCase tableName}}) 6 | export class {{pascalCase tableName}}Repository extends ExtendedRepository<{{pascalCase tableName}}> {} 7 | -------------------------------------------------------------------------------- /src/upload/upload.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | 5 | import { UploadResolver } from './upload.resolver'; 6 | import { UploadService } from './upload.service'; 7 | 8 | @Module({ 9 | imports: [ConfigModule, HttpModule.register({})], 10 | providers: [UploadService, UploadResolver], 11 | }) 12 | export class UploadModule {} 13 | -------------------------------------------------------------------------------- /src/auth/models/access-token.payload.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | 3 | import { User, UserRole, UserRoleType } from 'src/user/entities/user.entity'; 4 | 5 | @ObjectType() 6 | export class AccessTokenPayload implements Partial { 7 | @Field(() => ID) 8 | id: string; 9 | 10 | @Field(() => UserRole) 11 | role: UserRoleType; 12 | 13 | @Field(() => String, { nullable: true }) 14 | refreshToken?: string; 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json ./ 6 | COPY yarn.lock ./ 7 | 8 | RUN yarn install 9 | 10 | COPY . . 11 | 12 | RUN yarn build 13 | 14 | FROM node:20-alpine AS runner 15 | 16 | WORKDIR /app 17 | COPY --from=builder --chown=node:node /app/dist ./dist 18 | COPY --from=builder --chown=node:node /app/node_modules ./node_modules 19 | 20 | ENV NODE_ENV=production 21 | USER node 22 | 23 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /src/common/exceptions/exception.constant.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | 3 | export const GRAPHQL_ERROR_CODES = [ 4 | 'GRAPHQL_PARSE_FAILED', 5 | 'GRAPHQL_VALIDATION_FAILED', 6 | 'BAD_USER_INPUT', 7 | 'PERSISTED_QUERY_NOT_FOUND', 8 | 'PERSISTED_QUERY_NOT_SUPPORTED', 9 | 'OPERATION_RESOLUTION_FAILURE', 10 | ]; 11 | 12 | export const PRESERVED_STATUS_CODES = [ 13 | HttpStatus.UNAUTHORIZED, 14 | HttpStatus.FORBIDDEN, 15 | ] as const; 16 | -------------------------------------------------------------------------------- /src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { TerminusModule } from '@nestjs/terminus'; 4 | 5 | import { HealthController } from './health.controller'; 6 | import { PingIndicator } from './indicator/ping.indicator'; 7 | 8 | @Module({ 9 | imports: [TerminusModule, HttpModule], 10 | controllers: [HealthController], 11 | providers: [PingIndicator], 12 | }) 13 | export class HealthModule {} 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "importOrder": [ 6 | "^@nestjs/(.*)$", 7 | "", 8 | "^src/(.*)$", 9 | "^[./]" 10 | ], 11 | "importOrderSeparation": true, 12 | "importOrderSortSpecifiers": true, 13 | "importOrderParserPlugins": [ 14 | "typescript", 15 | "[\"decorators-legacy\", {\"decoratorsBeforeExport\": true}]" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /additional.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | NODE_ENV: 'development' | 'production' | 'test'; 4 | DB_HOST: string; 5 | DB_PORT: string; 6 | DB_USER: string; 7 | DB_PASSWORD: string; 8 | DB_NAME: string; 9 | PORT: string; 10 | JWT_PRIVATE_KEY: string; 11 | JWT_PUBLIC_KEY: string; 12 | AWS_S3_ACCESS_KEY: string; 13 | AWS_S3_SECRET_KEY: string; 14 | AWS_S3_REGION: string; 15 | AWS_S3_BUCKET_NAME: string; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/auth/inputs/auth.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | 3 | import { IsNotEmpty } from 'class-validator'; 4 | 5 | @InputType() 6 | export class SignInInput { 7 | @Field(() => String) 8 | @IsNotEmpty() 9 | username: string; 10 | 11 | @Field(() => String) 12 | @IsNotEmpty() 13 | password: string; 14 | } 15 | 16 | @InputType() 17 | export class SignUpInput extends SignInInput { 18 | @Field(() => String) 19 | @IsNotEmpty() 20 | nickname: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/common/interceptors/timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | 8 | import { timeout } from 'rxjs/operators'; 9 | 10 | const REQUEST_TIMEOUT = 30000000; 11 | 12 | @Injectable() 13 | export class TimeoutInterceptor implements NestInterceptor { 14 | intercept(_: ExecutionContext, next: CallHandler) { 15 | return next.handle().pipe(timeout(REQUEST_TIMEOUT)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { TypeOrmExModule } from '../common/modules/typeorm.module'; 4 | import { UserRepository } from './user.repository'; 5 | import { UserResolver } from './user.resolver'; 6 | import { UserService } from './user.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmExModule.forCustomRepository([UserRepository])], 10 | providers: [UserResolver, UserService], 11 | exports: [UserService], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /src/common/guards/graphql-refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class RefreshGuard extends AuthGuard('jwt-refresh') { 7 | constructor() { 8 | super(); 9 | } 10 | 11 | getRequest(context: ExecutionContext) { 12 | const ctx = GqlExecutionContext.create(context); 13 | const req = ctx.getContext().req; 14 | return req; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/common/decorators/repository-interceptor.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, UseInterceptors, applyDecorators } from '@nestjs/common'; 2 | 3 | import { QueryIntercepter } from '../interceptors/repository.interceptor'; 4 | 5 | export const REPOSITORY_INTERCEPTOR = Symbol('REPOSITORY_INTERCEPTOR'); 6 | 7 | export const UseRepositoryInterceptor = ( 8 | entity: new (...args: unknown[]) => T, 9 | ) => 10 | applyDecorators( 11 | SetMetadata(REPOSITORY_INTERCEPTOR, entity), 12 | UseInterceptors(QueryIntercepter), 13 | ); 14 | -------------------------------------------------------------------------------- /src/common/guards/graphql-signin.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class SignInGuard extends AuthGuard('local') { 7 | constructor() { 8 | super(); 9 | } 10 | 11 | getRequest(context: ExecutionContext) { 12 | const ctx = GqlExecutionContext.create(context); 13 | const request = ctx.getContext().req; 14 | request.body = ctx.getArgs().input; 15 | return request; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/common/config/ormconfig.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; 4 | 5 | import { setTypeormConfig } from './ormconfig'; 6 | 7 | @Injectable() 8 | export class TypeORMConfigService implements TypeOrmOptionsFactory { 9 | constructor(private readonly configService: ConfigService) {} 10 | 11 | createTypeOrmOptions(): Promise | TypeOrmModuleOptions { 12 | return setTypeormConfig(this.configService); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env 38 | *.env 39 | !.example.env 40 | 41 | test/graphql-schema.gql -------------------------------------------------------------------------------- /src/common/decorators/auth-guard.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common'; 2 | 3 | import { UserRole, UserRoleType } from 'src/user/entities/user.entity'; 4 | 5 | import { GraphqlPassportAuthGuard } from '../guards/graphql-passport-auth.guard'; 6 | 7 | export const GUARD_ROLE = Symbol('GUARD_ROLE'); 8 | 9 | export const UseAuthGuard = (roles?: UserRoleType | UserRoleType[]) => 10 | applyDecorators( 11 | SetMetadata( 12 | GUARD_ROLE, 13 | roles ? (Array.isArray(roles) ? roles : [roles]) : [UserRole.USER], 14 | ), 15 | UseGuards(GraphqlPassportAuthGuard), 16 | ); 17 | -------------------------------------------------------------------------------- /src/common/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | 3 | import { createException } from './exception.factory'; 4 | 5 | const CustomException = createException(); 6 | 7 | export class CustomConflictException extends CustomException( 8 | HttpStatus.CONFLICT, 9 | '{{property}} already exists', 10 | 'CONFLICT', 11 | ) {} 12 | 13 | export class CustomBadRequestException extends CustomException( 14 | HttpStatus.BAD_REQUEST, 15 | '{{message}}', 16 | 'BAD_REQUEST', 17 | ) {} 18 | 19 | export class CustomUnauthorizedException extends CustomException( 20 | HttpStatus.UNAUTHORIZED, 21 | '{{message}}', 22 | 'UNAUTHORIZED', 23 | ) {} 24 | -------------------------------------------------------------------------------- /generator/templates/module.hbs: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmExModule } from 'src/common/modules/typeorm.module' 3 | import { {{pascalCase tableName}}Service } from './{{tableName}}.service'; 4 | import { {{pascalCase tableName}}Repository } from './{{tableName}}.repository'; 5 | import { {{pascalCase tableName}}Resolver } from './{{tableName}}.resolver'; 6 | 7 | @Module({ 8 | imports: [TypeOrmExModule.forCustomRepository([{{pascalCase tableName}}Repository])], 9 | providers: [{{pascalCase tableName}}Service, {{pascalCase tableName}}Resolver], 10 | exports: [{{pascalCase tableName}}Service], 11 | }) 12 | export class {{pascalCase tableName}}Module {} 13 | -------------------------------------------------------------------------------- /src/common/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | 8 | import { tap } from 'rxjs/operators'; 9 | 10 | @Injectable() 11 | export class LoggingInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler) { 13 | const date = new Date().toISOString(); 14 | const { fieldName } = context.getArgs()[3] ?? { fieldName: 'REST API' }; 15 | const message = `${date} Request-Response time of ${fieldName}`; 16 | console.time(message); 17 | return next.handle().pipe(tap(() => console.timeEnd(message))); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/health/indicator/ping.indicator.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { 3 | HealthCheckError, 4 | HealthIndicator, 5 | HealthIndicatorResult, 6 | HttpHealthIndicator, 7 | } from '@nestjs/terminus'; 8 | 9 | @Injectable() 10 | export class PingIndicator extends HealthIndicator { 11 | constructor(private http: HttpHealthIndicator) { 12 | super(); 13 | } 14 | 15 | async isHealthy(key: string, url: string): Promise { 16 | try { 17 | await this.http.pingCheck(key, url); 18 | return this.getStatus(key, true); 19 | } catch (error) { 20 | throw new HealthCheckError('failed', error.causes); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | container_name: nestjs_boilerplate 5 | restart: always 6 | build: . 7 | ports: 8 | - '${PORT}:${PORT}' 9 | depends_on: 10 | - postgres 11 | volumes: 12 | - .:/app 13 | - node_modules:/app/node_modules 14 | 15 | postgres: 16 | image: postgres 17 | container_name: postgres 18 | healthcheck: 19 | test: ['CMD', 'pg_isready', '-U', 'postgres'] 20 | restart: unless-stopped 21 | environment: 22 | POSTGRES_USER: ${DB_USER} 23 | POSTGRES_PASSWORD: ${DB_PASSWORD} 24 | TZ: 'UTC' 25 | PGTZ: 'UTC' 26 | ports: 27 | - '5432:5432' 28 | 29 | volumes: 30 | node_modules: 31 | -------------------------------------------------------------------------------- /docker-compose.e2e.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | container_name: nestjs_boilerplate 5 | build: . 6 | ports: 7 | - '${PORT}:${PORT}' 8 | depends_on: 9 | - postgres 10 | volumes: 11 | - .:/app 12 | - node_modules:/app/node_modules 13 | command: yarn test:e2e 14 | 15 | postgres: 16 | image: postgres 17 | container_name: postgres 18 | healthcheck: 19 | test: ['CMD', 'pg_isready', '-U', 'postgres'] 20 | restart: unless-stopped 21 | environment: 22 | POSTGRES_USER: ${DB_USER} 23 | POSTGRES_PASSWORD: ${DB_PASSWORD} 24 | TZ: 'UTC' 25 | PGTZ: 'UTC' 26 | ports: 27 | - '5432:5432' 28 | 29 | volumes: 30 | node_modules: 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | }, 21 | "include": ["additional.d.ts", "**/*.ts", "**/*.tsx", "**/*.mjs"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /src/common/decorators/query-guard.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, UseGuards, applyDecorators } from '@nestjs/common'; 2 | 3 | import { FindOptionsSelect } from 'typeorm'; 4 | 5 | import { GraphqlQueryPermissionGuard } from '../guards/graphql-query-permission.guard'; 6 | 7 | export type ClassConstructor = new (...args: unknown[]) => T; 8 | 9 | export const PERMISSION = Symbol('PERMISSION'); 10 | export const INSTANCE = Symbol('INSTANCE'); 11 | 12 | export const UseQueryPermissionGuard = ( 13 | instance: T, 14 | permission: FindOptionsSelect>, 15 | ) => 16 | applyDecorators( 17 | SetMetadata(INSTANCE, instance), 18 | SetMetadata(PERMISSION, permission), 19 | UseGuards(GraphqlQueryPermissionGuard), 20 | ); 21 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | 4 | import { Strategy } from 'passport-local'; 5 | 6 | import { AuthService } from '../auth.service'; 7 | import { AccessTokenPayload } from '../models/access-token.payload'; 8 | 9 | @Injectable() 10 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | usernameField: 'username', 14 | passwordField: 'password', 15 | }); 16 | } 17 | async validate( 18 | username: string, 19 | password: string, 20 | ): Promise { 21 | const user = await this.authService.validateUser({ username, password }); 22 | 23 | return user; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/common/exceptions/exception.util.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | import { GraphQLErrorExtensions } from 'graphql'; 4 | 5 | import { GRAPHQL_ERROR_CODES } from './exception.constant'; 6 | import { BaseException } from './exception.factory'; 7 | 8 | export const isGraphqlOriginalError = ( 9 | extensions: GraphQLErrorExtensions, 10 | ): boolean => { 11 | return ( 12 | typeof extensions?.code === 'string' && 13 | GRAPHQL_ERROR_CODES.includes(extensions.code) 14 | ); 15 | }; 16 | 17 | export const isBaseException = ( 18 | error: unknown, 19 | ): error is BaseException => { 20 | return error instanceof BaseException; 21 | }; 22 | 23 | export const isHttpException = (error: unknown): error is HttpException => { 24 | return error instanceof HttpException; 25 | }; 26 | -------------------------------------------------------------------------------- /generator/templates/input.hbs: -------------------------------------------------------------------------------- 1 | import 2 | {{#if columnRequired}} 3 | { IsNotEmpty, IsOptional } 4 | {{else}} 5 | { IsOptional } 6 | {{/if}} 7 | from 'class-validator' 8 | import { Field, InputType } from '@nestjs/graphql' 9 | 10 | @InputType() 11 | export class Create{{pascalCase tableName}}Input { 12 | @Field(()=>{{pascalCase columnType}} 13 | {{#unless columnRequired}} 14 | , { nullable: true } 15 | {{/unless}} 16 | ) 17 | {{#if columnRequired}} 18 | @IsNotEmpty() 19 | {{else}} 20 | @IsOptional() 21 | {{/if}} 22 | {{columnName}} 23 | {{#unless columnRequired}} 24 | ? 25 | {{/unless}} 26 | : {{columnType}} 27 | } 28 | 29 | @InputType() 30 | export class Update{{pascalCase tableName}}Input { 31 | @Field(()=>{{pascalCase columnType}} 32 | {{#unless columnRequired}} 33 | , { nullable: true } 34 | {{/unless}} 35 | ) 36 | @IsOptional() 37 | {{columnName}} 38 | {{#unless columnRequired}} 39 | ? 40 | {{/unless}} 41 | : {{columnType}} 42 | } 43 | -------------------------------------------------------------------------------- /src/cache/custom-cache.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule, CacheModuleOptions } from '@nestjs/cache-manager'; 2 | import { DynamicModule, Module, OnModuleInit } from '@nestjs/common'; 3 | import { APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core'; 4 | 5 | import { CustomCacheInterceptor } from './custom-cache.interceptor'; 6 | import { CustomCacheService } from './custom-cache.service'; 7 | 8 | @Module({}) 9 | export class CustomCacheModule implements OnModuleInit { 10 | constructor(private readonly customCacheService: CustomCacheService) {} 11 | 12 | static forRoot(options?: CacheModuleOptions): DynamicModule { 13 | return { 14 | module: CustomCacheModule, 15 | imports: [CacheModule.register(options), DiscoveryModule], 16 | providers: [ 17 | CustomCacheService, 18 | { provide: APP_INTERCEPTOR, useClass: CustomCacheInterceptor }, 19 | ], 20 | global: true, 21 | }; 22 | } 23 | 24 | onModuleInit() { 25 | this.customCacheService.registerAllCache(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/exceptions/exception.plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloServerPlugin, 3 | BaseContext, 4 | GraphQLRequestContext, 5 | } from '@apollo/server'; 6 | 7 | import { errorFormatter } from './exception.format'; 8 | 9 | export const httpStatusPlugin: ApolloServerPlugin = { 10 | async requestDidStart() { 11 | return { 12 | async willSendResponse( 13 | requestContext: GraphQLRequestContext, 14 | ) { 15 | const { response, errors } = requestContext; 16 | 17 | if (!errors || errors.length === 0) { 18 | return; 19 | } 20 | 21 | const { statusCode, response: formattedResponse } = errorFormatter( 22 | errors, 23 | response.body.kind === 'single' 24 | ? response.body.singleResult.data 25 | : null, 26 | ); 27 | 28 | response.body = { 29 | kind: 'single', 30 | singleResult: formattedResponse, 31 | }; 32 | response.http.status = statusCode; 33 | }, 34 | }; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/common/interceptors/repository.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { GqlExecutionContext } from '@nestjs/graphql'; 8 | 9 | import { Observable } from 'rxjs'; 10 | import { DataSource, EntityTarget, ObjectLiteral } from 'typeorm'; 11 | 12 | import { REPOSITORY_INTERCEPTOR } from '../decorators/repository-interceptor.decorator'; 13 | 14 | @Injectable() 15 | export class QueryIntercepter< 16 | T extends ObjectLiteral, 17 | > implements NestInterceptor { 18 | constructor(private readonly dataSource: DataSource) {} 19 | 20 | intercept(context: ExecutionContext, next: CallHandler): Observable { 21 | const ctx = GqlExecutionContext.create(context); 22 | 23 | const entity: EntityTarget = Reflect.getMetadata( 24 | REPOSITORY_INTERCEPTOR, 25 | ctx.getHandler(), 26 | ); 27 | 28 | const baseRepository = this.dataSource.getRepository(entity); 29 | 30 | const request = ctx.getContext().req; 31 | request.repository = baseRepository; 32 | 33 | return next.handle(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joo-Byungho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { 3 | DiskHealthIndicator, 4 | HealthCheck, 5 | HealthCheckService, 6 | MemoryHealthIndicator, 7 | TypeOrmHealthIndicator, 8 | } from '@nestjs/terminus'; 9 | 10 | import { PingIndicator } from './indicator/ping.indicator'; 11 | 12 | @Controller('health') 13 | export class HealthController { 14 | constructor( 15 | private health: HealthCheckService, 16 | private ormIndicator: TypeOrmHealthIndicator, 17 | private memory: MemoryHealthIndicator, 18 | private disk: DiskHealthIndicator, 19 | private ping: PingIndicator, 20 | ) {} 21 | 22 | @Get() 23 | @HealthCheck() 24 | check() { 25 | return this.health.check([ 26 | () => this.ormIndicator.pingCheck('database', { timeout: 15000 }), 27 | () => this.memory.checkHeap('memory_heap', 1000 * 1024 * 1024), 28 | () => this.memory.checkRSS('memory_RSS', 1000 * 1024 * 1024), 29 | () => 30 | this.disk.checkStorage('disk_health', { 31 | thresholdPercent: 10, 32 | path: '/', 33 | }), 34 | () => this.ping.isHealthy('nestjs-docs', 'https://nestjs.com/'), 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | 7 | import { CustomUnauthorizedException } from 'src/common/exceptions'; 8 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 9 | 10 | import { UserService } from '../../user/user.service'; 11 | import { AccessTokenPayload } from '../models/access-token.payload'; 12 | 13 | @Injectable() 14 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 15 | constructor( 16 | private readonly userService: UserService, 17 | private readonly configService: ConfigService, 18 | ) { 19 | super({ 20 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 21 | secretOrKey: configService.get('JWT_PUBLIC_KEY'), 22 | }); 23 | } 24 | 25 | async validate(payload: AccessTokenPayload): Promise { 26 | const doesExist = await this.userService.doesExist({ id: payload.id }); 27 | 28 | if (!doesExist) { 29 | throw new CustomUnauthorizedException(); 30 | } 31 | 32 | return payload; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/common/graphql/custom.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, Int } from '@nestjs/graphql'; 2 | 3 | import { IsNotEmpty, IsOptional } from 'class-validator'; 4 | import GraphQLJSON from 'graphql-type-json'; 5 | import { FindOptionsOrder } from 'typeorm'; 6 | 7 | import { IWhere } from './utils/types'; 8 | 9 | @InputType() 10 | export class IPagination { 11 | @Field(() => Int, { description: 'Started from 0' }) 12 | @IsNotEmpty() 13 | page: number; 14 | 15 | @Field(() => Int, { description: 'Size of page' }) 16 | @IsNotEmpty() 17 | size: number; 18 | } 19 | 20 | @InputType() 21 | export class GetOneInput { 22 | @Field(() => GraphQLJSON) 23 | @IsNotEmpty() 24 | where: IWhere; 25 | } 26 | 27 | @InputType() 28 | export class GetManyInput { 29 | @Field(() => GraphQLJSON, { nullable: true }) 30 | @IsOptional() 31 | where?: IWhere; 32 | 33 | @Field(() => IPagination, { nullable: true }) 34 | @IsOptional() 35 | pagination?: IPagination; 36 | 37 | @Field(() => GraphQLJSON, { 38 | nullable: true, 39 | description: 40 | '{key: "ASC" or "DESC" or "asc" or "desc" or 1 or -1} or {key: {direction: "ASC" or "DESC" or "asc" or "desc", nulls: "first" or "last" or "FIRST" or "LAST"}}}', 41 | }) 42 | @IsOptional() 43 | order?: FindOptionsOrder; 44 | } 45 | -------------------------------------------------------------------------------- /src/common/modules/typeorm.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Provider } from '@nestjs/common'; 2 | import { getDataSourceToken } from '@nestjs/typeorm'; 3 | 4 | import { DataSource } from 'typeorm'; 5 | 6 | import { TYPEORM_CUSTOM_REPOSITORY } from '../decorators/typeorm.decorator'; 7 | 8 | export class TypeOrmExModule { 9 | public static forCustomRepository any>( 10 | repositories: T[], 11 | ): DynamicModule { 12 | const providers: Provider[] = []; 13 | 14 | for (const repository of repositories) { 15 | const entity = Reflect.getMetadata(TYPEORM_CUSTOM_REPOSITORY, repository); 16 | 17 | if (!entity) { 18 | continue; 19 | } 20 | 21 | providers.push({ 22 | inject: [getDataSourceToken()], 23 | provide: repository, 24 | useFactory: (dataSource: DataSource): typeof repository => { 25 | const baseRepository = dataSource.getRepository(entity); 26 | return new repository( 27 | baseRepository.target, 28 | baseRepository.manager, 29 | baseRepository.queryRunner, 30 | ); 31 | }, 32 | }); 33 | } 34 | 35 | return { 36 | exports: providers, 37 | module: TypeOrmExModule, 38 | providers, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/common/factory/mockFactory.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from 'typeorm'; 2 | 3 | import { ExtendedRepository } from 'src/common/graphql/customExtended'; 4 | 5 | const putMockedFunction = (propsNames: string[]) => { 6 | return propsNames 7 | .filter((key: string) => key !== 'constructor') 8 | .reduce((fncs, key: string) => { 9 | fncs[key] = jest.fn(); 10 | return fncs; 11 | }, {}); 12 | }; 13 | 14 | export type MockRepository = Partial< 15 | Record, jest.Mock> 16 | >; 17 | 18 | export class MockRepositoryFactory { 19 | static getMockRepository( 20 | repository: new (...args: unknown[]) => T, 21 | ): () => MockRepository { 22 | return () => 23 | putMockedFunction([ 24 | ...Object.getOwnPropertyNames(Repository.prototype), 25 | ...Object.getOwnPropertyNames(ExtendedRepository.prototype), 26 | ...Object.getOwnPropertyNames(repository.prototype), 27 | ]); 28 | } 29 | } 30 | 31 | export type MockService = Partial>; 32 | 33 | export class MockServiceFactory { 34 | static getMockService( 35 | service: new (...args: unknown[]) => T, 36 | ): () => MockService { 37 | return () => 38 | putMockedFunction(Object.getOwnPropertyNames(service.prototype)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt-refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | 7 | import { CustomUnauthorizedException } from 'src/common/exceptions'; 8 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 9 | 10 | import { AuthService } from '../auth.service'; 11 | import { AccessTokenPayload } from '../models/access-token.payload'; 12 | 13 | @Injectable() 14 | export class JwtRefreshStrategy extends PassportStrategy( 15 | Strategy, 16 | 'jwt-refresh', 17 | ) { 18 | constructor( 19 | private readonly configService: ConfigService, 20 | private readonly authService: AuthService, 21 | ) { 22 | super({ 23 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 24 | secretOrKey: configService.get('JWT_PUBLIC_KEY'), 25 | }); 26 | } 27 | 28 | async validate(payload: AccessTokenPayload): Promise { 29 | const doesExist = await this.authService.verifyRefreshToken( 30 | payload.id, 31 | payload.refreshToken, 32 | ); 33 | 34 | if (!doesExist) { 35 | throw new CustomUnauthorizedException(); 36 | } 37 | 38 | return payload; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/common/format/graphql-error.format.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLError, 3 | GraphQLErrorExtensions, 4 | GraphQLFormattedError, 5 | } from 'graphql'; 6 | 7 | interface CustomGraphQLErrorExtenssions extends GraphQLErrorExtensions { 8 | exception?: { 9 | message: string; 10 | status: number; 11 | }; 12 | 13 | response?: { 14 | message: string; 15 | statusCode: number; 16 | }; 17 | } 18 | 19 | export const formatError = (error: GraphQLError) => { 20 | const extensions = error.extensions as CustomGraphQLErrorExtenssions; 21 | const standardError: GraphQLFormattedError = { 22 | message: error.extensions?.message || error.message, 23 | ...error, 24 | extensions: { 25 | __orginal: { 26 | ...extensions, 27 | }, 28 | code: error.extensions?.code || 'UNKNOWN ERROR', 29 | message: error.extensions?.message || error.message, 30 | }, 31 | }; 32 | // HTTP Exception 33 | if (extensions?.exception) { 34 | standardError.extensions.message = extensions.exception.message; 35 | standardError.extensions.status = extensions.exception.status; 36 | } 37 | // Class vaildation Exception 38 | if (extensions?.response) { 39 | standardError.extensions.message = extensions.response.message; 40 | standardError.extensions.status = extensions.response.statusCode; 41 | } 42 | return standardError; 43 | }; 44 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { FindOptionsWhere } from 'typeorm'; 4 | 5 | import { CustomCache } from 'src/cache/custom-cache.decorator'; 6 | import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types'; 7 | 8 | import { User } from './entities/user.entity'; 9 | import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; 10 | import { UserRepository } from './user.repository'; 11 | 12 | @Injectable() 13 | export class UserService { 14 | constructor(private readonly userRepository: UserRepository) {} 15 | 16 | @CustomCache({ logger: console.log, ttl: 1000 }) 17 | getMany(option?: RepoQuery) { 18 | return this.userRepository.getMany(option); 19 | } 20 | 21 | getOne(option: OneRepoQuery) { 22 | return this.userRepository.getOne(option); 23 | } 24 | 25 | doesExist(where: FindOptionsWhere) { 26 | return this.userRepository.exists({ where }); 27 | } 28 | 29 | create(input: CreateUserInput): Promise { 30 | const user = this.userRepository.create(input); 31 | 32 | return this.userRepository.save(user); 33 | } 34 | 35 | update(id: string, input: UpdateUserInput) { 36 | const user = this.userRepository.create(input); 37 | 38 | return this.userRepository.update(id, user); 39 | } 40 | 41 | delete(id: string) { 42 | return this.userRepository.delete({ id }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/user/inputs/user.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | 3 | import { IsNotEmpty, IsOptional } from 'class-validator'; 4 | 5 | import { User, UserRole, UserRoleType } from '../entities/user.entity'; 6 | 7 | @InputType() 8 | export class CreateUserInput implements Partial { 9 | @Field(() => String) 10 | @IsNotEmpty() 11 | username: string; 12 | 13 | @Field(() => String) 14 | @IsNotEmpty() 15 | password: string; 16 | 17 | @Field(() => String) 18 | @IsNotEmpty() 19 | nickname: string; 20 | 21 | @Field(() => UserRole) 22 | @IsNotEmpty() 23 | role: UserRoleType; 24 | 25 | @Field(() => String, { nullable: true }) 26 | @IsOptional() 27 | refreshToken?: string; 28 | } 29 | 30 | @InputType() 31 | export class UpdateUserInput implements Partial { 32 | @Field(() => String, { nullable: true }) 33 | @IsOptional() 34 | username?: string; 35 | 36 | @Field(() => String, { nullable: true }) 37 | @IsOptional() 38 | password?: string; 39 | 40 | @Field(() => String, { nullable: true }) 41 | @IsOptional() 42 | nickname?: string; 43 | 44 | @Field(() => UserRole, { nullable: true }) 45 | @IsOptional() 46 | role?: UserRoleType; 47 | 48 | @Field(() => String, { nullable: true }) 49 | @IsOptional() 50 | refreshToken?: string; 51 | } 52 | 53 | @InputType() 54 | export class UserIdInput { 55 | @Field(() => String) 56 | @IsNotEmpty() 57 | id: string; 58 | } 59 | -------------------------------------------------------------------------------- /src/common/exceptions/exception.factory.ts: -------------------------------------------------------------------------------- 1 | type ExceptionParams< 2 | T extends string, 3 | U extends string = never, 4 | > = T extends `${infer _}{{${infer K}}}${infer Rest}` 5 | ? ExceptionParams 6 | : { 7 | [key in U]: string | number; 8 | }; 9 | 10 | export abstract class BaseException< 11 | StatusCode extends number, 12 | Message extends string, 13 | Code extends string, 14 | > extends Error { 15 | constructor( 16 | public statusCode: StatusCode, 17 | public override message: Message, 18 | public code: Code, 19 | ) { 20 | super(message); 21 | } 22 | } 23 | 24 | export const createException = () => { 25 | return ( 26 | statusCode: T, 27 | defaultMessage: K, 28 | code: U, 29 | ) => { 30 | return class extends BaseException { 31 | constructor(arg?: ExceptionParams) { 32 | super(statusCode, defaultMessage, code); 33 | 34 | if (arg) { 35 | this.message = composeExceptionMessage(defaultMessage, arg) as K; 36 | } 37 | } 38 | }; 39 | }; 40 | }; 41 | 42 | const composeExceptionMessage = ( 43 | message: T, 44 | options: Record = {}, 45 | ) => { 46 | return message.replace( 47 | /{{([a-zA-Z0-9_-]+)}}/g, 48 | (_: string, matched: string) => { 49 | return String(options[matched] ?? ''); 50 | }, 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/common/config/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | 3 | import { config } from 'dotenv'; 4 | import { join } from 'path'; 5 | import { cwd, env } from 'process'; 6 | import { DataSource, DataSourceOptions } from 'typeorm'; 7 | 8 | import { getEnvPath } from '../helper/env.helper'; 9 | 10 | config({ 11 | path: getEnvPath(cwd()), 12 | }); 13 | 14 | export const setTypeormConfig = ( 15 | conf: NodeJS.ProcessEnv | ConfigService, 16 | ): DataSourceOptions => { 17 | const getConfigValue = 18 | conf instanceof ConfigService 19 | ? conf.get.bind(conf) 20 | : (key: string) => conf[key]; 21 | 22 | return { 23 | type: 'postgres', 24 | host: getConfigValue('DB_HOST'), 25 | port: Number(getConfigValue('DB_PORT')), 26 | username: getConfigValue('DB_USER'), 27 | password: getConfigValue('DB_PASSWORD'), 28 | database: getConfigValue('DB_NAME'), 29 | entities: 30 | getConfigValue('NODE_ENV') === 'test' 31 | ? [join(cwd(), 'src', '**', '*.entity.{ts,js}')] 32 | : [join(cwd(), 'dist', '**', '*.entity.js')], 33 | synchronize: getConfigValue('NODE_ENV') !== 'production', 34 | dropSchema: getConfigValue('NODE_ENV') === 'test', 35 | migrations: [ 36 | join(cwd(), 'dist', 'common', 'database', 'migrations', '*{.ts,.js}'), 37 | ], 38 | migrationsRun: false, 39 | logging: false, 40 | }; 41 | }; 42 | 43 | export default new DataSource(setTypeormConfig(env)); 44 | -------------------------------------------------------------------------------- /src/common/guards/graphql-passport-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { AuthGuard } from '@nestjs/passport'; 5 | 6 | import { UserRole, UserRoleType } from 'src/user/entities/user.entity'; 7 | 8 | import { GUARD_ROLE } from '../decorators/auth-guard.decorator'; 9 | 10 | @Injectable() 11 | export class GraphqlPassportAuthGuard extends AuthGuard('jwt') { 12 | constructor(private reflector: Reflector) { 13 | super(); 14 | } 15 | 16 | async canActivate(context: ExecutionContext): Promise { 17 | const requiredRoles = this.reflector.get( 18 | GUARD_ROLE, 19 | context.getHandler(), 20 | ); 21 | await super.canActivate(context); 22 | const ctx = GqlExecutionContext.create(context); 23 | const req = ctx.getContext().req; 24 | const { role } = req.user; 25 | 26 | if (role === UserRole.ADMIN) { 27 | return true; 28 | } 29 | 30 | return this.hasAccess(role, requiredRoles); 31 | } 32 | 33 | getRequest(context: ExecutionContext) { 34 | const ctx = GqlExecutionContext.create(context); 35 | const req = ctx.getContext().req; 36 | return req; 37 | } 38 | 39 | private hasAccess( 40 | role: UserRoleType, 41 | requiredRoles: UserRoleType[], 42 | ): boolean { 43 | return requiredRoles.some((v: UserRoleType) => v === role); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { GraphQLModule } from '@nestjs/graphql'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | 7 | import { AuthModule } from './auth/auth.module'; 8 | import { CustomCacheModule } from './cache/custom-cache.module'; 9 | import { GraphqlConfigService } from './common/config/graphql-config.service'; 10 | import { TypeORMConfigService } from './common/config/ormconfig.service'; 11 | import { getEnvPath } from './common/helper/env.helper'; 12 | import { envValidation } from './common/helper/env.validation'; 13 | import { HealthModule } from './health/health.module'; 14 | import { UploadModule } from './upload/upload.module'; 15 | import { UserModule } from './user/user.module'; 16 | 17 | @Module({ 18 | imports: [ 19 | ConfigModule.forRoot({ 20 | envFilePath: getEnvPath(`${__dirname}/..`), 21 | validate: envValidation, 22 | }), 23 | GraphQLModule.forRootAsync({ 24 | driver: ApolloDriver, 25 | useClass: GraphqlConfigService, 26 | imports: [ConfigModule], 27 | }), 28 | TypeOrmModule.forRootAsync({ 29 | useClass: TypeORMConfigService, 30 | imports: [ConfigModule], 31 | }), 32 | UserModule, 33 | AuthModule, 34 | UploadModule, 35 | HealthModule, 36 | CustomCacheModule.forRoot(), 37 | ], 38 | }) 39 | export class AppModule {} 40 | -------------------------------------------------------------------------------- /src/common/graphql/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindOptionsOrder, 3 | FindOptionsRelations, 4 | FindOptionsSelect, 5 | } from 'typeorm'; 6 | 7 | import { IPagination } from './custom.input'; 8 | import { IWhere } from './utils/types'; 9 | 10 | export const valueObj = { 11 | ASC: 'ASC', 12 | DESC: 'DESC', 13 | asc: 'asc', 14 | desc: 'desc', 15 | 1: 1, 16 | '-1': -1, 17 | } as const; 18 | 19 | const direction = ['ASC', 'DESC', 'asc', 'desc'] as const; 20 | type DirectionUnion = (typeof direction)[number]; 21 | 22 | const nulls = ['first', 'last', 'FIRST', 'LAST'] as const; 23 | type NullsUnion = (typeof nulls)[number]; 24 | 25 | export const checkObject = { 26 | direction, 27 | nulls, 28 | }; 29 | 30 | export const directionObj = { 31 | direction: 'direction', 32 | nulls: 'nulls', 33 | } as const; 34 | 35 | type IDirectionWitnNulls = { 36 | [directionObj.direction]?: DirectionUnion; 37 | [directionObj.nulls]?: NullsUnion; 38 | }; 39 | 40 | export type IDriection = (typeof valueObj)[keyof typeof valueObj]; 41 | export type ISort = IDriection | IDirectionWitnNulls; 42 | 43 | export interface IGetData { 44 | data?: T[]; 45 | count?: number; 46 | } 47 | 48 | export interface RepoQuery { 49 | pagination?: IPagination; 50 | where?: IWhere; 51 | order?: FindOptionsOrder; 52 | relations?: FindOptionsRelations; 53 | select?: FindOptionsSelect; 54 | } 55 | 56 | export type OneRepoQuery = Required, 'where'>> & 57 | Pick, 'relations' | 'select'>; 58 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | 6 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 7 | import { UserModule } from 'src/user/user.module'; 8 | 9 | import { AuthResolver } from './auth.resolver'; 10 | import { AuthService } from './auth.service'; 11 | import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; 12 | import { JwtStrategy } from './strategies/jwt.strategy'; 13 | import { LocalStrategy } from './strategies/local.strategy'; 14 | 15 | @Module({ 16 | imports: [ 17 | PassportModule.register({ defaultStrategy: 'jwt' }), 18 | JwtModule.registerAsync({ 19 | imports: [ConfigModule], 20 | inject: [ConfigService], 21 | useFactory: (configService: ConfigService) => ({ 22 | privateKey: configService.get('JWT_PRIVATE_KEY'), 23 | publicKey: configService.get('JWT_PUBLIC_KEY'), 24 | signOptions: { 25 | algorithm: 'RS256', 26 | expiresIn: '1d', 27 | }, 28 | verifyOptions: { 29 | algorithms: ['RS256'], 30 | }, 31 | }), 32 | }), 33 | ConfigModule, 34 | UserModule, 35 | ], 36 | providers: [ 37 | AuthResolver, 38 | AuthService, 39 | JwtStrategy, 40 | LocalStrategy, 41 | JwtRefreshStrategy, 42 | ], 43 | }) 44 | export class AuthModule {} 45 | -------------------------------------------------------------------------------- /generator/templates/entity.hbs: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | {{#if (isIn "createdAt" columnList)}} 4 | CreateDateColumn, 5 | {{/if}} 6 | Entity, 7 | PrimaryGeneratedColumn, 8 | {{#if (isIn "updatedAt" columnList)}} 9 | UpdateDateColumn, 10 | {{/if}} 11 | } from 'typeorm' 12 | import { Field, ID, ObjectType } from '@nestjs/graphql' 13 | 14 | @ObjectType() 15 | @Entity() 16 | export class {{pascalCase tableName}} { 17 | @Field(() => ID) 18 | @PrimaryGeneratedColumn('{{idType}}') 19 | {{#if (is "increment" idType)}} 20 | id: number; 21 | {{else}} 22 | id: string; 23 | {{/if}} 24 | 25 | @Field(() => {{pascalCase columnType}} 26 | {{#unless columnRequired}} 27 | , { nullable: true } 28 | {{/unless}} 29 | ) 30 | 31 | @Column( 32 | {{#unless columnRequired}} 33 | { nullable: true } 34 | {{/unless}} 35 | ) 36 | {{columnName}} 37 | {{#unless columnRequired}} 38 | ? 39 | {{/unless}} 40 | : {{columnType}} 41 | 42 | {{#if (isIn "createdAt" columnList)}} 43 | @Field() 44 | @CreateDateColumn({ 45 | type: 'timestamp with time zone', 46 | }) 47 | createdAt: Date; 48 | {{/if}} 49 | {{#if (isIn "updatedAt" columnList)}} 50 | @Field() 51 | @UpdateDateColumn({ 52 | type: 'timestamp with time zone', 53 | }) 54 | updatedAt: Date; 55 | {{/if}} 56 | 57 | } 58 | 59 | @ObjectType() 60 | export class Get{{pascalCase tableName}}Type { 61 | @Field(() => [{{pascalCase tableName}}], { nullable: true }) 62 | data?: {{pascalCase tableName}}[]; 63 | 64 | @Field(() => Number, { nullable: true }) 65 | count?: number; 66 | } 67 | -------------------------------------------------------------------------------- /generator/templates/service.hbs: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | 3 | import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types' 4 | 5 | import { {{pascalCase tableName}}Repository } from './{{tableName}}.repository' 6 | import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity'; 7 | import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input'; 8 | 9 | @Injectable() 10 | export class {{pascalCase tableName}}Service { 11 | constructor(private readonly {{tableName}}Repository: {{pascalCase tableName}}Repository) {} 12 | getMany(option?: RepoQuery<{{pascalCase tableName}}>) { 13 | return this.{{tableName}}Repository.getMany(option); 14 | } 15 | 16 | getOne(option: OneRepoQuery<{{pascalCase tableName}}>) { 17 | return this.{{tableName}}Repository.getOne(option); 18 | } 19 | 20 | create(input: Create{{pascalCase tableName}}Input) { 21 | const {{tableName}} = this.{{tableName}}Repository.create(input) 22 | 23 | return this.{{tableName}}Repository.save({{tableName}}); 24 | } 25 | 26 | update( 27 | {{#if (is "increment" idType)}} 28 | id: number, 29 | {{else}} 30 | id: string, 31 | {{/if}} 32 | input: Update{{pascalCase tableName}}Input 33 | ) { 34 | const {{tableName}} = this.{{tableName}}Repository.create(input) 35 | 36 | return this.{{tableName}}Repository.update(id, {{tableName}}) 37 | } 38 | 39 | delete( 40 | {{#if (is "increment" idType)}} 41 | id: number, 42 | {{else}} 43 | id: string, 44 | {{/if}} 45 | ) { 46 | return this.{{tableName}}Repository.delete({ id }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/upload/upload.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Resolver } from '@nestjs/graphql'; 2 | 3 | import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; 4 | import type { FileUpload } from 'graphql-upload/processRequest.mjs'; 5 | 6 | import { UploadService } from './upload.service'; 7 | 8 | @Resolver() 9 | export class UploadResolver { 10 | private readonly FOLDER_NAME = 'someFolderName'; 11 | constructor(private readonly uploadService: UploadService) {} 12 | 13 | @Mutation(() => String) 14 | async uploadFile( 15 | @Args({ name: 'file', type: () => GraphQLUpload }) 16 | file: FileUpload, 17 | ): Promise { 18 | const { key } = await this.uploadService.uploadFileToS3({ 19 | folderName: this.FOLDER_NAME, 20 | file, 21 | }); 22 | 23 | return this.uploadService.getLinkByKey(key); 24 | } 25 | 26 | @Mutation(() => [String]) 27 | uploadFiles( 28 | @Args({ name: 'files', type: () => [GraphQLUpload] }) 29 | files: FileUpload[], 30 | ): Promise { 31 | return Promise.all( 32 | files.map(async (file) => { 33 | const { key } = await this.uploadService.uploadFileToS3({ 34 | folderName: this.FOLDER_NAME, 35 | file, 36 | }); 37 | 38 | return this.uploadService.getLinkByKey(key); 39 | }), 40 | ); 41 | } 42 | 43 | @Mutation(() => Boolean) 44 | async deleteFiles( 45 | @Args({ name: 'keys', type: () => [String] }) keys: string[], 46 | ) { 47 | const mapped = keys.map((key) => key.split('s3.amazonaws.com/')[1]); 48 | for await (const key of mapped) { 49 | this.uploadService.deleteS3Object(key); 50 | } 51 | return true; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import globals from 'globals'; 6 | import path from 'node:path'; 7 | import { fileURLToPath } from 'node:url'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | { 19 | ignores: ['**/eslint.config.mjs'], 20 | }, 21 | ...compat.extends( 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:prettier/recommended', 24 | ), 25 | { 26 | plugins: { 27 | '@typescript-eslint': typescriptEslint, 28 | }, 29 | 30 | languageOptions: { 31 | globals: { 32 | ...globals.node, 33 | ...globals.jest, 34 | }, 35 | 36 | parser: tsParser, 37 | ecmaVersion: 5, 38 | sourceType: 'module', 39 | 40 | parserOptions: { 41 | project: 'tsconfig.json', 42 | tsconfigRootDir: __dirname, 43 | }, 44 | }, 45 | 46 | rules: { 47 | '@typescript-eslint/interface-name-prefix': 'off', 48 | '@typescript-eslint/explicit-function-return-type': 'off', 49 | '@typescript-eslint/explicit-module-boundary-types': 'off', 50 | '@typescript-eslint/no-explicit-any': 'off', 51 | '@typescript-eslint/no-unused-vars': [ 52 | 'warn', 53 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 54 | ], 55 | }, 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /src/common/util/util.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | import { v4 } from 'uuid'; 5 | 6 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 7 | 8 | @Injectable() 9 | export class UtilService { 10 | constructor( 11 | private readonly configService: ConfigService, 12 | ) {} 13 | 14 | getNumber(key: keyof EnvironmentVariables): number { 15 | const value = this.configService.get(key); 16 | 17 | try { 18 | return Number(value); 19 | } catch { 20 | throw new Error(key + ' environment variable is not a number'); 21 | } 22 | } 23 | 24 | getString(key: keyof EnvironmentVariables): string { 25 | const value = this.configService.get(key); 26 | 27 | return value.replace(/\\n/g, '\n'); 28 | } 29 | 30 | getRandomNumber(min: number, max: number): number { 31 | return Math.floor(Math.random() * (max - min + 1)) + min; 32 | } 33 | 34 | pick(instance: T, keys: K[]) { 35 | return keys.reduce( 36 | (picked, key) => { 37 | if (key in instance) { 38 | picked[key] = instance[key]; 39 | } 40 | 41 | return picked; 42 | }, 43 | {} as Pick, 44 | ); 45 | } 46 | 47 | get getRandomUUID() { 48 | return v4(); 49 | } 50 | 51 | get nodeEnv() { 52 | return this.getString('NODE_ENV') as 'development' | 'production' | 'test'; 53 | } 54 | 55 | get isDevelopment(): boolean { 56 | return this.nodeEnv === 'development'; 57 | } 58 | 59 | get isProduction(): boolean { 60 | return this.nodeEnv === 'production'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/auth/auth.resolver.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards } from '@nestjs/common'; 2 | import { Args, Mutation, Resolver } from '@nestjs/graphql'; 3 | 4 | import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input'; 5 | import { CurrentUser } from 'src/common/decorators/user.decorator'; 6 | import { RefreshGuard } from 'src/common/guards/graphql-refresh.guard'; 7 | import { SignInGuard } from 'src/common/guards/graphql-signin.guard'; 8 | import { UserService } from 'src/user/user.service'; 9 | 10 | import { AuthService } from './auth.service'; 11 | import { AccessTokenPayload } from './models/access-token.payload'; 12 | import { JwtWithUser } from './models/auth.model'; 13 | 14 | @Resolver() 15 | export class AuthResolver { 16 | constructor( 17 | private readonly authService: AuthService, 18 | private readonly userService: UserService, 19 | ) {} 20 | 21 | @Mutation(() => JwtWithUser) 22 | @UseGuards(SignInGuard) 23 | signIn( 24 | @Args('input') _: SignInInput, 25 | @CurrentUser() user: AccessTokenPayload, 26 | ) { 27 | return this.authService.signIn(user); 28 | } 29 | 30 | @Mutation(() => JwtWithUser) 31 | signUp(@Args('input') input: SignUpInput) { 32 | return this.authService.signUp(input); 33 | } 34 | 35 | @Mutation(() => Boolean) 36 | @UseGuards(RefreshGuard) 37 | async signOut(@CurrentUser() user: AccessTokenPayload) { 38 | await this.userService.update(user.id, { refreshToken: null }); 39 | return true; 40 | } 41 | 42 | @Mutation(() => JwtWithUser) 43 | @UseGuards(RefreshGuard) 44 | refreshAccessToken(@CurrentUser() user: AccessTokenPayload) { 45 | const jwt = this.authService.generateAccessToken(user, user.refreshToken); 46 | 47 | return { jwt, user }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/common/config/graphql-config.service.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriverConfig } from '@nestjs/apollo'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { GqlOptionsFactory } from '@nestjs/graphql'; 5 | 6 | import { 7 | ApolloServerPluginLandingPageLocalDefault, 8 | ApolloServerPluginLandingPageProductionDefault, 9 | } from '@apollo/server/plugin/landingPage/default'; 10 | import GraphQLJSON from 'graphql-type-json'; 11 | import { join } from 'path'; 12 | import { cwd } from 'process'; 13 | 14 | import { httpStatusPlugin } from '../exceptions/exception.plugin'; 15 | import { EnvironmentVariables } from '../helper/env.validation'; 16 | 17 | @Injectable() 18 | export class GraphqlConfigService implements GqlOptionsFactory { 19 | constructor( 20 | private readonly configService: ConfigService, 21 | ) {} 22 | 23 | createGqlOptions(): Promise | ApolloDriverConfig { 24 | return { 25 | resolvers: { JSON: GraphQLJSON }, 26 | autoSchemaFile: join( 27 | cwd(), 28 | `${this.configService.get('NODE_ENV') === 'test' ? 'test' : 'src'}/graphql-schema.gql`, 29 | ), 30 | sortSchema: true, 31 | playground: false, 32 | plugins: [ 33 | httpStatusPlugin, 34 | this.configService.get('NODE_ENV') === 'production' 35 | ? ApolloServerPluginLandingPageProductionDefault() 36 | : ApolloServerPluginLandingPageLocalDefault(), 37 | ], 38 | 39 | context: ({ req }) => ({ req }), 40 | cache: 'bounded', 41 | csrfPrevention: this.configService.get('NODE_ENV') !== 'development', 42 | introspection: this.configService.get('NODE_ENV') === 'development', 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-check.yml: -------------------------------------------------------------------------------- 1 | name: Perform a build check when a PR is opened 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | # https://github.com/actions/checkout 11 | # This action checks out the repository's code onto the runner. 12 | # This is necessary so that the workflow can access and operate on the code. 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | # https://github.com/actions/setup-node 17 | # This action sets up a specific version of Node.js on the runner. 18 | # It also caches the specified package manager (in this case, yarn) for faster future runs. 19 | - name: Setup Node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | cache: 'yarn' 24 | 25 | # https://github.com/actions/cache 26 | # This action restores cache from a key. 27 | # It helps to reduce the time to install dependencies by reusing the previously cached dependencies. 28 | - name: Restore cache 29 | uses: actions/cache@v4 30 | with: 31 | path: | 32 | node_modules 33 | # Generate a new cache whenever packages or source files change. 34 | key: ${{ runner.os }}-nestjs-${{ hashFiles('**/package.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 35 | # Change the cache only when the package file is changed, not the source file. 36 | restore-keys: | 37 | ${{ runner.os }}-nestjs-${{ hashFiles('**/package.json', '**/yarn.lock') }}- 38 | 39 | - name: Install dependencies 40 | run: yarn install 41 | 42 | - name: Build with Nestjs.js 43 | run: yarn build 44 | 45 | - name: Set env 46 | run: | 47 | echo "${{ secrets.ENV }}" > ./.test.env 48 | 49 | - name: Do Test 50 | run: yarn test 51 | -------------------------------------------------------------------------------- /src/common/helper/env.validation.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { 3 | IsEnum, 4 | IsNotEmpty, 5 | IsNumber, 6 | IsOptional, 7 | IsString, 8 | Max, 9 | Min, 10 | validateSync, 11 | } from 'class-validator'; 12 | 13 | enum NODE_ENVIRONMENT { 14 | development, 15 | production, 16 | test, 17 | } 18 | 19 | export class EnvironmentVariables { 20 | @IsEnum(NODE_ENVIRONMENT) 21 | NODE_ENV: keyof typeof NODE_ENVIRONMENT; 22 | 23 | @IsString() 24 | @IsNotEmpty() 25 | DB_HOST: string; 26 | 27 | @IsString() 28 | @IsNotEmpty() 29 | DB_NAME: string; 30 | 31 | @IsString() 32 | @IsNotEmpty() 33 | DB_USER: string; 34 | 35 | @IsString() 36 | @IsNotEmpty() 37 | DB_PASSWORD: string; 38 | 39 | @IsNumber() 40 | @Min(0) 41 | @Max(65535) 42 | @IsNotEmpty() 43 | DB_PORT: number; 44 | 45 | @IsNumber() 46 | @Min(0) 47 | @Max(65535) 48 | @IsNotEmpty() 49 | PORT: number; 50 | 51 | @IsString() 52 | @IsNotEmpty() 53 | JWT_PRIVATE_KEY: string; 54 | 55 | @IsString() 56 | @IsNotEmpty() 57 | JWT_PUBLIC_KEY: string; 58 | 59 | @IsString() 60 | @IsNotEmpty() 61 | JWT_REFRESH_TOKEN_PRIVATE_KEY: string; 62 | 63 | @IsString() 64 | @IsOptional() 65 | AWS_S3_ACCESS_KEY?: string; 66 | 67 | @IsString() 68 | @IsOptional() 69 | AWS_S3_SECRET_KEY?: string; 70 | 71 | @IsString() 72 | @IsOptional() 73 | AWS_S3_REGION?: string; 74 | 75 | @IsString() 76 | @IsOptional() 77 | AWS_S3_BUCKET_NAME?: string; 78 | } 79 | 80 | export function envValidation(config: Record) { 81 | const validatedConfig = plainToInstance(EnvironmentVariables, config, { 82 | enableImplicitConversion: true, 83 | }); 84 | const errors = validateSync(validatedConfig, { 85 | skipMissingProperties: false, 86 | }); 87 | 88 | if (errors.length) { 89 | throw new Error(errors.toString()); 90 | } 91 | return validatedConfig; 92 | } 93 | -------------------------------------------------------------------------------- /src/user/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'; 2 | 3 | import * as bcrypt from 'bcrypt'; 4 | import { 5 | BeforeInsert, 6 | BeforeUpdate, 7 | Column, 8 | CreateDateColumn, 9 | Entity, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | 14 | const BCRYPT_HASH_ROUNDS = 10; 15 | 16 | export const UserRole = { 17 | USER: 'USER', 18 | ADMIN: 'ADMIN', 19 | } as const; 20 | 21 | export type UserRoleType = (typeof UserRole)[keyof typeof UserRole]; 22 | 23 | registerEnumType(UserRole, { 24 | name: 'UserRole', 25 | }); 26 | 27 | @ObjectType() 28 | @Entity() 29 | export class User { 30 | @Field(() => ID) 31 | @PrimaryGeneratedColumn('uuid') 32 | id: string; 33 | 34 | @Field(() => String) 35 | @Column() 36 | username: string; 37 | 38 | @Column({ nullable: true }) 39 | password: string; 40 | 41 | @Field(() => String) 42 | @Column() 43 | nickname: string; 44 | 45 | @Field(() => UserRole) 46 | @Column({ 47 | type: 'enum', 48 | enum: UserRole, 49 | default: UserRole.USER, 50 | }) 51 | role: UserRoleType; 52 | 53 | @Field(() => Date) 54 | @CreateDateColumn({ 55 | type: 'timestamp with time zone', 56 | }) 57 | createdAt: Date; 58 | 59 | @Field(() => Date) 60 | @UpdateDateColumn({ 61 | type: 'timestamp with time zone', 62 | }) 63 | updatedAt: Date; 64 | 65 | @Field(() => String, { nullable: true }) 66 | @Column({ nullable: true }) 67 | refreshToken?: string; 68 | 69 | @BeforeInsert() 70 | @BeforeUpdate() 71 | async beforeInsertOrUpdate() { 72 | if (this.password) { 73 | this.password = await bcrypt.hash(this.password, BCRYPT_HASH_ROUNDS); 74 | } 75 | } 76 | } 77 | 78 | @ObjectType() 79 | export class GetUserType { 80 | @Field(() => [User], { nullable: true }) 81 | data?: User[]; 82 | 83 | @Field(() => Number, { nullable: true }) 84 | count?: number; 85 | } 86 | -------------------------------------------------------------------------------- /src/cache/custom-cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { GqlExecutionContext } from '@nestjs/graphql'; 9 | 10 | import { Observable, from, of, switchMap, tap } from 'rxjs'; 11 | 12 | import { CUSTOM_CACHE, CustomCacheOptions } from './custom-cache.decorator'; 13 | import { CustomCacheService } from './custom-cache.service'; 14 | 15 | @Injectable() 16 | export class CustomCacheInterceptor implements NestInterceptor { 17 | constructor( 18 | private readonly customCacheService: CustomCacheService, 19 | private readonly reflector: Reflector, 20 | ) {} 21 | 22 | intercept(context: ExecutionContext, next: CallHandler): Observable { 23 | const handler = context.getHandler(); 24 | const options = this.reflector.get( 25 | CUSTOM_CACHE, 26 | handler, 27 | ); 28 | 29 | if (!options) { 30 | return next.handle(); 31 | } 32 | 33 | const { key, ttl, logger } = options; 34 | const customKey = `${context.getClass().name}.${handler.name}`; 35 | const args = this.getArgs(context); 36 | const cacheKey = this.customCacheService.buildCacheKey( 37 | key ?? customKey, 38 | args, 39 | ); 40 | 41 | return from(this.customCacheService.getCache(cacheKey, logger)).pipe( 42 | switchMap((cached) => 43 | cached !== undefined 44 | ? of(cached) 45 | : next 46 | .handle() 47 | .pipe( 48 | tap((data) => 49 | this.customCacheService.setCache(cacheKey, data, ttl, logger), 50 | ), 51 | ), 52 | ), 53 | ); 54 | } 55 | 56 | private getArgs(context: ExecutionContext): unknown[] { 57 | const ctx = GqlExecutionContext.create(context); 58 | return Object.values(ctx.getArgs()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/common/guards/graphql-query-permission.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | 5 | import { DataSource, FindOptionsSelect } from 'typeorm'; 6 | 7 | import { 8 | getCurrentGraphQLQuery, 9 | getOptionFromGqlQuery, 10 | } from '../decorators/option.decorator'; 11 | import { 12 | ClassConstructor, 13 | INSTANCE, 14 | PERMISSION, 15 | } from '../decorators/query-guard.decorator'; 16 | import { GetInfoFromQueryProps } from '../graphql/utils/types'; 17 | 18 | const checkPermission = ( 19 | permission: FindOptionsSelect>, 20 | select: FindOptionsSelect>, 21 | ): boolean => { 22 | return Object.entries(permission) 23 | .filter((v) => !!v[1]) 24 | .every(([key, value]) => { 25 | if (typeof value === 'boolean') { 26 | return select[key] ? false : true; 27 | } 28 | 29 | return checkPermission(value, select[key]); 30 | }); 31 | }; 32 | 33 | @Injectable() 34 | export class GraphqlQueryPermissionGuard { 35 | constructor( 36 | private reflector: Reflector, 37 | private readonly dataSource: DataSource, 38 | ) {} 39 | 40 | canActivate(context: ExecutionContext): boolean { 41 | const permission = this.reflector.get>>( 42 | PERMISSION, 43 | context.getHandler(), 44 | ); 45 | 46 | const entity = this.reflector.get(INSTANCE, context.getHandler()); 47 | const repository = this.dataSource.getRepository(entity); 48 | 49 | const ctx = GqlExecutionContext.create(context); 50 | const query = getCurrentGraphQLQuery(ctx); 51 | 52 | const { select }: GetInfoFromQueryProps> = 53 | getOptionFromGqlQuery.call(repository, query); 54 | 55 | return checkPermission(permission, select); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/common/graphql/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindOptionsRelations, 3 | FindOptionsSelect, 4 | FindOptionsWhereProperty, 5 | } from 'typeorm'; 6 | 7 | // You can see detail operations usage in process-where.md in root 8 | 9 | // Not equal 10 | type TNe = { 11 | $ne: unknown; 12 | }; 13 | 14 | // Less than 15 | type TLt = { 16 | $lt: number | Date; 17 | }; 18 | 19 | // Less than or equal 20 | type TLte = { 21 | $lte: number | Date; 22 | }; 23 | 24 | // Greater than 25 | type TGt = { 26 | $gt: number | Date; 27 | }; 28 | 29 | // Greater than or equal 30 | type TGte = { 31 | $gte: number | Date; 32 | }; 33 | 34 | // In 35 | type TIn = { 36 | $in: T[keyof T][]; 37 | }; 38 | 39 | // Not in 40 | type TNotIn = { 41 | $nIn: unknown[]; 42 | }; 43 | 44 | // Contains(Case-sensitive) 45 | type TContains = { 46 | $contains: string | number; 47 | }; 48 | 49 | // Not contains(Case-sensitive) 50 | type TNotContains = { 51 | $nContains: unknown; 52 | }; 53 | 54 | // Contains(Case-insensitive) 55 | type TIContains = { 56 | $iContains: string | number; 57 | }; 58 | 59 | // Not contains(Case-insensitive) 60 | type TNotIContains = { 61 | $nIContains: unknown; 62 | }; 63 | 64 | // Is null 65 | type TNull = { 66 | $null: boolean; 67 | }; 68 | 69 | // Is not null 70 | type TNotNull = { 71 | $nNull: boolean; 72 | }; 73 | 74 | // Is between 75 | type TBetween = { 76 | $between: [number, number] | [Date, Date] | [string, string]; 77 | }; 78 | 79 | export type OperatorType = 80 | | TNe 81 | | TLt 82 | | TLte 83 | | TGt 84 | | TGte 85 | | TIn 86 | | TNotIn 87 | | TContains 88 | | TNotContains 89 | | TNull 90 | | TNotNull 91 | | TBetween 92 | | TIContains 93 | | TNotIContains; 94 | 95 | type ExtendedFindOptionsWhere = { 96 | [P in keyof Entity]?: P extends 'toString' 97 | ? unknown 98 | : 99 | | FindOptionsWhereProperty> 100 | | OperatorType 101 | | Entity[P] 102 | | ExtendedFindOptionsWhere; 103 | }; 104 | 105 | export type IWhere = 106 | | ExtendedFindOptionsWhere 107 | | ExtendedFindOptionsWhere[]; 108 | 109 | export interface GetInfoFromQueryProps { 110 | relations: FindOptionsRelations; 111 | select: FindOptionsSelect; 112 | } 113 | 114 | export interface AddKeyValueInObjectProps< 115 | Entity, 116 | > extends GetInfoFromQueryProps { 117 | stack: string[]; 118 | expandRelation?: boolean; 119 | } 120 | -------------------------------------------------------------------------------- /process-where.md: -------------------------------------------------------------------------------- 1 | ### Equal 2 | 3 | where: { 4 | user: { 5 | id: 3 6 | } 7 | } 8 | 9 | ### Contains(case-sensitive) 10 | 11 | where: { 12 | user: { 13 | id: { 14 | $contains: 3 15 | } 16 | } 17 | } 18 | 19 | ### Not equal 20 | 21 | where: { 22 | user: { 23 | id: { 24 | $ne: 3 25 | } 26 | } 27 | } 28 | 29 | ### Not Contains(case-sensitive) 30 | 31 | where: { 32 | user: { 33 | id: { 34 | $nContains: 3 35 | } 36 | } 37 | } 38 | 39 | ### Less than 40 | 41 | where: { 42 | user: { 43 | id: { 44 | $lt: 3 45 | } 46 | } 47 | } 48 | 49 | ### Is null 50 | 51 | where: { 52 | user: { 53 | id: { 54 | $null: true 55 | } 56 | } 57 | } 58 | 59 | ### Less than or equal 60 | 61 | where: { 62 | user: { 63 | id: { 64 | $lte: 3 65 | } 66 | } 67 | } 68 | 69 | ### Is not null 70 | 71 | where: { 72 | user: { 73 | id: { 74 | $notNull: true 75 | } 76 | } 77 | } 78 | 79 | ### Greate than 80 | 81 | where: { 82 | user: { 83 | id: { 84 | $gt: 3 85 | } 86 | } 87 | } 88 | 89 | ### Is between 90 | 91 | where: { 92 | user: { 93 | id: { 94 | $between: [3, 4] 95 | } 96 | } 97 | } 98 | 99 | ### Greater than or equal 100 | 101 | where: { 102 | user: { 103 | id: { 104 | $gte: 3 105 | } 106 | } 107 | } 108 | 109 | ### Joins the where in an "or" expression 110 | 111 | where: [ 112 | user: { 113 | id: 3 114 | } 115 | place: { 116 | id: 3 117 | } 118 | ] 119 | 120 | ### In 121 | 122 | where: { 123 | user: { 124 | id: { 125 | $in: [1, 2, 3] 126 | } 127 | } 128 | } 129 | 130 | ### Joins the where in an "and" expression 131 | 132 | where: { 133 | user: { 134 | id: 3, 135 | nickname: "man", 136 | } 137 | } 138 | 139 | ### Not in 140 | 141 | where: { 142 | user: { 143 | id: { 144 | $nIn: [1, 2, 3] 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { NestExpressApplication } from '@nestjs/platform-express'; 5 | 6 | import { NextFunction } from 'express'; 7 | import { Request, Response } from 'express'; 8 | import express from 'express'; 9 | import { GraphQLFormattedError } from 'graphql'; 10 | import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; 11 | 12 | import { AppModule } from './app.module'; 13 | import { GraphQLExceptionSilencer } from './common/exceptions/exception-silencer.filter'; 14 | import { EnvironmentVariables } from './common/helper/env.validation'; 15 | import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; 16 | import { TimeoutInterceptor } from './common/interceptors/timeout.interceptor'; 17 | 18 | const GRAPHQL_HEADER_KEY = 'application/graphql-response+json'; 19 | 20 | async function bootstrap() { 21 | const app = await NestFactory.create(AppModule); 22 | 23 | app.useGlobalInterceptors(new TimeoutInterceptor(), new LoggingInterceptor()); 24 | 25 | app.useGlobalPipes( 26 | new ValidationPipe({ 27 | whitelist: true, 28 | transform: true, 29 | transformOptions: { 30 | enableImplicitConversion: true, 31 | }, 32 | }), 33 | ); 34 | 35 | app.useGlobalFilters(new GraphQLExceptionSilencer()); 36 | 37 | app.use(express.json()); 38 | 39 | app.use( 40 | '/graphql', 41 | graphqlUploadExpress({ maxFileSize: 1000 * 1000 * 10, maxFiles: 10 }), 42 | (req: Request, res: Response, next: NextFunction) => { 43 | const accept = req.headers.accept || ''; 44 | if (accept.includes(GRAPHQL_HEADER_KEY) || req.method !== 'POST') { 45 | return next(); 46 | } 47 | 48 | res.status(HttpStatus.NOT_ACCEPTABLE).json({ 49 | data: null, 50 | extensions: { 51 | errorStatus: HttpStatus.NOT_ACCEPTABLE, 52 | errorCode: 'NOT_ACCEPTABLE', 53 | }, 54 | errors: [ 55 | { 56 | message: 57 | 'Not Acceptable: Server supports application/graphql-response+json only.', 58 | }, 59 | ] as ReadonlyArray, 60 | }); 61 | }, 62 | ); 63 | 64 | app.use(function (req: Request, res: Response, next: NextFunction) { 65 | if (req.originalUrl && req.originalUrl.split('/').pop() === 'favicon.ico') { 66 | return res.sendStatus(204); 67 | } 68 | 69 | next(); 70 | }); 71 | 72 | app.enableCors({ 73 | origin: '*', 74 | credentials: true, 75 | }); 76 | const configService = app 77 | .select(AppModule) 78 | .get(ConfigService); 79 | 80 | await app.listen(configService.get('PORT')); 81 | } 82 | bootstrap(); 83 | -------------------------------------------------------------------------------- /src/graphql-schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type AccessTokenPayload { 6 | id: ID! 7 | refreshToken: String 8 | role: UserRole! 9 | } 10 | 11 | input CreateUserInput { 12 | nickname: String! 13 | password: String! 14 | refreshToken: String 15 | role: UserRole! 16 | username: String! 17 | } 18 | 19 | """ 20 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. 21 | """ 22 | scalar DateTime 23 | 24 | input GetManyInput { 25 | """ 26 | {key: "ASC" or "DESC" or "asc" or "desc" or 1 or -1} or {key: {direction: "ASC" or "DESC" or "asc" or "desc", nulls: "first" or "last" or "FIRST" or "LAST"}}} 27 | """ 28 | order: JSON 29 | pagination: IPagination 30 | where: JSON 31 | } 32 | 33 | input GetOneInput { 34 | where: JSON! 35 | } 36 | 37 | type GetUserType { 38 | count: Float 39 | data: [User!] 40 | } 41 | 42 | input IPagination { 43 | """Started from 0""" 44 | page: Int! 45 | 46 | """Size of page""" 47 | size: Int! 48 | } 49 | 50 | """ 51 | The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). 52 | """ 53 | scalar JSON 54 | 55 | type JwtWithUser { 56 | jwt: String! 57 | user: AccessTokenPayload! 58 | } 59 | 60 | type Mutation { 61 | createUser(input: CreateUserInput!): User! 62 | deleteFiles(keys: [String!]!): Boolean! 63 | deleteUser(id: String!): JSON! 64 | refreshAccessToken: JwtWithUser! 65 | signIn(input: SignInInput!): JwtWithUser! 66 | signOut: Boolean! 67 | signUp(input: SignUpInput!): JwtWithUser! 68 | updateUser(id: String!, input: UpdateUserInput!): JSON! 69 | uploadFile(file: Upload!): String! 70 | uploadFiles(files: [Upload!]!): [String!]! 71 | } 72 | 73 | type Query { 74 | getManyUserList(input: GetManyInput): GetUserType! 75 | getMe: User! 76 | getOneUser(input: GetOneInput!): User! 77 | } 78 | 79 | input SignInInput { 80 | password: String! 81 | username: String! 82 | } 83 | 84 | input SignUpInput { 85 | nickname: String! 86 | password: String! 87 | username: String! 88 | } 89 | 90 | input UpdateUserInput { 91 | nickname: String 92 | password: String 93 | refreshToken: String 94 | role: UserRole 95 | username: String 96 | } 97 | 98 | """The `Upload` scalar type represents a file upload.""" 99 | scalar Upload 100 | 101 | type User { 102 | createdAt: DateTime! 103 | id: ID! 104 | nickname: String! 105 | refreshToken: String 106 | role: UserRole! 107 | updatedAt: DateTime! 108 | username: String! 109 | } 110 | 111 | enum UserRole { 112 | ADMIN 113 | USER 114 | } -------------------------------------------------------------------------------- /src/user/user.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 2 | 3 | import GraphQLJSON from 'graphql-type-json'; 4 | 5 | import { AccessTokenPayload } from 'src/auth/models/access-token.payload'; 6 | import { CustomCache } from 'src/cache/custom-cache.decorator'; 7 | import { UseAuthGuard } from 'src/common/decorators/auth-guard.decorator'; 8 | import { GraphQLQueryToOption } from 'src/common/decorators/option.decorator'; 9 | import { UseRepositoryInterceptor } from 'src/common/decorators/repository-interceptor.decorator'; 10 | import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input'; 11 | import { GetInfoFromQueryProps } from 'src/common/graphql/utils/types'; 12 | 13 | import { CurrentUser } from '../common/decorators/user.decorator'; 14 | import { GetUserType, User, UserRole } from './entities/user.entity'; 15 | import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; 16 | import { UserService } from './user.service'; 17 | 18 | @Resolver() 19 | export class UserResolver { 20 | constructor(private readonly userService: UserService) {} 21 | 22 | @Query(() => GetUserType) 23 | @UseAuthGuard(UserRole.ADMIN) 24 | @UseRepositoryInterceptor(User) 25 | @CustomCache({ logger: console.log, ttl: 1000 }) 26 | getManyUserList( 27 | @Args({ name: 'input', nullable: true }) 28 | condition: GetManyInput, 29 | @GraphQLQueryToOption() 30 | option: GetInfoFromQueryProps, 31 | ) { 32 | return this.userService.getMany({ ...condition, ...option }); 33 | } 34 | 35 | @Query(() => User) 36 | @UseAuthGuard(UserRole.ADMIN) 37 | @UseRepositoryInterceptor(User) 38 | getOneUser( 39 | @Args({ name: 'input' }) 40 | condition: GetOneInput, 41 | @GraphQLQueryToOption() 42 | option: GetInfoFromQueryProps, 43 | ) { 44 | return this.userService.getOne({ ...condition, ...option }); 45 | } 46 | 47 | @Mutation(() => User) 48 | @UseAuthGuard(UserRole.ADMIN) 49 | createUser(@Args('input') input: CreateUserInput) { 50 | return this.userService.create(input); 51 | } 52 | 53 | @Mutation(() => GraphQLJSON) 54 | @UseAuthGuard(UserRole.ADMIN) 55 | updateUser(@Args('id') id: string, @Args('input') input: UpdateUserInput) { 56 | return this.userService.update(id, input); 57 | } 58 | 59 | @Mutation(() => GraphQLJSON) 60 | @UseAuthGuard(UserRole.ADMIN) 61 | deleteUser(@Args('id') id: string) { 62 | return this.userService.delete(id); 63 | } 64 | 65 | @Query(() => User) 66 | @UseAuthGuard() 67 | @UseRepositoryInterceptor(User) 68 | getMe( 69 | @CurrentUser() user: AccessTokenPayload, 70 | @GraphQLQueryToOption() 71 | option: GetInfoFromQueryProps, 72 | ) { 73 | return this.userService.getOne({ 74 | where: { id: user.id }, 75 | ...option, 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /generator/templates/resolver.hbs: -------------------------------------------------------------------------------- 1 | import { UseAuthGuard } from 'src/common/decorators/auth-guard.decorator'; 2 | import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' 3 | import { {{pascalCase tableName}}Service } from './{{tableName}}.service' 4 | import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input' 5 | import GraphQLJSON from 'graphql-type-json'; 6 | 7 | import { GetInfoFromQueryProps } from 'src/common/graphql/utils/types'; 8 | import { GraphQLQueryToOption } from 'src/common/decorators/option.decorator'; 9 | import { UseRepositoryInterceptor } from 'src/common/decorators/repository-interceptor.decorator'; 10 | import { UserRole } from 'src/user/entities/user.entity'; 11 | 12 | import { Get{{pascalCase tableName}}Type, {{pascalCase tableName}} } from './entities/{{tableName}}.entity'; 13 | import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input'; 14 | 15 | @Resolver() 16 | export class {{pascalCase tableName}}Resolver { 17 | constructor(private readonly {{tableName}}Service: {{pascalCase tableName}}Service) {} 18 | 19 | @Query(() => Get{{pascalCase tableName}}Type) 20 | @UseAuthGuard(UserRole.ADMIN) 21 | @UseRepositoryInterceptor({{pascalCase tableName}}) 22 | getMany{{pascalCase tableName}}List( 23 | @Args({ name: 'input', nullable: true }) condition: GetManyInput<{{pascalCase tableName}}>, 24 | @GraphQLQueryToOption<{{pascalCase tableName}}>() 25 | option: GetInfoFromQueryProps<{{pascalCase tableName}}>, 26 | ) { 27 | return this.{{tableName}}Service.getMany({ ...condition, ...option }); 28 | } 29 | 30 | @Query(() => {{pascalCase tableName}}) 31 | @UseAuthGuard(UserRole.ADMIN) 32 | @UseRepositoryInterceptor({{pascalCase tableName}}) 33 | getOne{{pascalCase tableName}}( 34 | @Args({ name: 'input' }) condition: GetOneInput<{{pascalCase tableName}}>, 35 | @GraphQLQueryToOption<{{pascalCase tableName}}>() 36 | option: GetInfoFromQueryProps<{{pascalCase tableName}}>, 37 | ) { 38 | return this.{{tableName}}Service.getOne({ ...condition, ...option }); 39 | } 40 | 41 | @Mutation(() => {{pascalCase tableName}}) 42 | @UseAuthGuard(UserRole.ADMIN) 43 | create{{pascalCase tableName}}(@Args('input') input: Create{{pascalCase tableName}}Input) { 44 | return this.{{tableName}}Service.create(input); 45 | } 46 | 47 | @Mutation(() => GraphQLJSON) 48 | @UseAuthGuard(UserRole.ADMIN) 49 | update{{pascalCase tableName}}(@Args('id') 50 | {{#if (is "increment" idType)}} 51 | id: number, 52 | {{else}} 53 | id: string, 54 | {{/if}} 55 | @Args('input') input: Update{{pascalCase tableName}}Input) { 56 | return this.{{tableName}}Service.update(id, input); 57 | } 58 | 59 | @Mutation(() => GraphQLJSON) 60 | @UseAuthGuard(UserRole.ADMIN) 61 | delete{{pascalCase tableName}}(@Args('id') 62 | {{#if (is "increment" idType)}} 63 | id: number, 64 | {{else}} 65 | id: string, 66 | {{/if}} 67 | ) { 68 | return this.{{tableName}}Service.delete(id); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { getRepositoryToken } from '@nestjs/typeorm'; 3 | 4 | import { 5 | MockRepository, 6 | MockRepositoryFactory, 7 | } from 'src/common/factory/mockFactory'; 8 | import { ExtendedRepository } from 'src/common/graphql/customExtended'; 9 | import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types'; 10 | import { UtilModule } from 'src/common/util/util.module'; 11 | import { UtilService } from 'src/common/util/util.service'; 12 | 13 | import { User } from './entities/user.entity'; 14 | import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; 15 | import { UserRepository } from './user.repository'; 16 | import { UserService } from './user.service'; 17 | 18 | describe('UserService', () => { 19 | let service: UserService; 20 | let mockedRepository: MockRepository>; 21 | let utilService: UtilService; 22 | 23 | beforeAll(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | imports: [UtilModule], 26 | providers: [ 27 | UserService, 28 | { 29 | provide: getRepositoryToken(UserRepository), 30 | useFactory: MockRepositoryFactory.getMockRepository(UserRepository), 31 | }, 32 | ], 33 | }).compile(); 34 | 35 | utilService = module.get(UtilService); 36 | service = module.get(UserService); 37 | mockedRepository = module.get>>( 38 | getRepositoryToken(UserRepository), 39 | ); 40 | }); 41 | 42 | afterEach(() => { 43 | jest.resetAllMocks(); 44 | }); 45 | 46 | it('Calling "Get many" method', () => { 47 | const option: RepoQuery = { 48 | where: { id: utilService.getRandomUUID }, 49 | }; 50 | 51 | expect(service.getMany(option)).not.toEqual(null); 52 | expect(mockedRepository.getMany).toHaveBeenCalled(); 53 | }); 54 | 55 | it('Calling "Get one" method', () => { 56 | const option: OneRepoQuery = { 57 | where: { id: utilService.getRandomUUID }, 58 | }; 59 | 60 | expect(service.getOne(option)).not.toEqual(null); 61 | expect(mockedRepository.getOne).toHaveBeenCalled(); 62 | }); 63 | 64 | it('Calling "Create" method', () => { 65 | const dto = new CreateUserInput(); 66 | const user = mockedRepository.create(dto); 67 | 68 | expect(service.create(dto)).not.toEqual(null); 69 | expect(mockedRepository.create).toHaveBeenCalledWith(dto); 70 | expect(mockedRepository.save).toHaveBeenCalledWith(user); 71 | }); 72 | 73 | it('Calling "Update" method', () => { 74 | const id = utilService.getRandomUUID; 75 | const dto = new UpdateUserInput(); 76 | const user = mockedRepository.create(dto); 77 | 78 | service.update(id, dto); 79 | 80 | expect(mockedRepository.create).toHaveBeenCalledWith(dto); 81 | expect(mockedRepository.update).toHaveBeenCalledWith(id, user); 82 | }); 83 | 84 | it('Calling "Delete" method', () => { 85 | const id = utilService.getRandomUUID; 86 | 87 | service.delete(id); 88 | 89 | expect(mockedRepository.delete).toHaveBeenCalledWith({ id }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/cache/custom-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core'; 4 | 5 | import { CUSTOM_CACHE, CustomCacheOptions } from './custom-cache.decorator'; 6 | 7 | @Injectable() 8 | export class CustomCacheService { 9 | constructor( 10 | private readonly discoveryService: DiscoveryService, 11 | private readonly metadataScanner: MetadataScanner, 12 | private readonly reflector: Reflector, 13 | @Inject(CACHE_MANAGER) 14 | private readonly cacheManager: Cache, 15 | ) {} 16 | 17 | registerAllCache() { 18 | return this.discoveryService 19 | .getProviders() 20 | .filter((wrapper) => wrapper.isDependencyTreeStatic()) 21 | .filter(({ instance }) => instance && Object.getPrototypeOf(instance)) 22 | .forEach(({ instance }) => { 23 | const prototype = Object.getPrototypeOf(instance); 24 | const methods = this.metadataScanner.getAllMethodNames(prototype); 25 | 26 | methods.forEach(this.registerCache(instance)); 27 | }); 28 | } 29 | 30 | private registerCache(instance: object) { 31 | return (methodName: string) => { 32 | const methodRef = instance[methodName]; 33 | 34 | const options = this.reflector.get( 35 | CUSTOM_CACHE, 36 | methodRef, 37 | ); 38 | if (!options) { 39 | return; 40 | } 41 | 42 | const customKey = `${instance.constructor.name}.${methodName}`; 43 | 44 | const methodOverride = async (...args: unknown[]) => { 45 | const result = async () => await methodRef.apply(instance, args); 46 | 47 | return this.getOrSetCache(customKey, args, options, result); 48 | }; 49 | 50 | Object.defineProperty(instance, methodName, { 51 | value: methodOverride.bind(instance), 52 | }); 53 | }; 54 | } 55 | 56 | async getCache(key: string, logger?: CustomCacheOptions['logger']) { 57 | const cached = await this.cacheManager.get(key); 58 | if (cached !== undefined) { 59 | logger?.('Cache Hit', { cacheKey: key }); 60 | } 61 | return cached; 62 | } 63 | 64 | async setCache( 65 | key: string, 66 | data: unknown, 67 | ttl: number = Infinity, 68 | logger?: CustomCacheOptions['logger'], 69 | ) { 70 | await this.cacheManager.set(key, data, ttl); 71 | logger?.('Cached', { cacheKey: key }); 72 | } 73 | 74 | buildCacheKey(customKey: string, args: unknown[]) { 75 | return customKey + JSON.stringify(args); 76 | } 77 | 78 | async getOrSetCache( 79 | customKey: string, 80 | args: unknown[], 81 | options: CustomCacheOptions, 82 | resultFn: () => Promise, 83 | ) { 84 | const { key: cacheKey = customKey, ttl = Infinity, logger } = options; 85 | const argsAddedKey = this.buildCacheKey(cacheKey, args); 86 | 87 | const cachedValue = await this.getCache(argsAddedKey, logger); 88 | if (cachedValue !== undefined) { 89 | return cachedValue; 90 | } 91 | 92 | const result = await resultFn(); 93 | await this.setCache(argsAddedKey, result, ttl, logger); 94 | 95 | return result; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/upload/upload.module.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { HttpStatus, INestApplication } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { Query, Resolver } from '@nestjs/graphql'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | 7 | import { S3 } from '@aws-sdk/client-s3'; 8 | import { Upload } from '@aws-sdk/lib-storage'; 9 | import * as request from 'supertest'; 10 | 11 | import { getEnvPath } from 'src/common/helper/env.helper'; 12 | 13 | import { UploadService } from './upload.service'; 14 | 15 | jest.mock('@aws-sdk/client-s3'); 16 | jest.mock('@aws-sdk/lib-storage'); 17 | 18 | @Resolver() 19 | class DummyResolver { 20 | @Query(() => String) 21 | _dummy(): string { 22 | return 'dummy'; 23 | } 24 | } 25 | 26 | describe('UploadModule', () => { 27 | let app: INestApplication; 28 | let mockS3Send: jest.Mock; 29 | 30 | beforeAll(async () => { 31 | mockS3Send = jest.fn(); 32 | (S3 as jest.Mock).mockImplementation(() => ({ 33 | send: mockS3Send, 34 | })); 35 | 36 | (Upload as unknown as jest.Mock).mockImplementation(() => ({ 37 | done: jest.fn().mockResolvedValue({}), 38 | })); 39 | 40 | const { ApolloDriver } = await import('@nestjs/apollo'); 41 | const { GraphQLModule } = await import('@nestjs/graphql'); 42 | const { UploadResolver } = await import('./upload.resolver'); 43 | 44 | const module: TestingModule = await Test.createTestingModule({ 45 | imports: [ 46 | ConfigModule.forRoot({ 47 | isGlobal: true, 48 | envFilePath: getEnvPath(process.cwd()), 49 | }), 50 | GraphQLModule.forRoot({ 51 | driver: ApolloDriver, 52 | autoSchemaFile: true, 53 | }), 54 | HttpModule.register({}), 55 | ], 56 | providers: [UploadService, UploadResolver, DummyResolver], 57 | }).compile(); 58 | 59 | app = module.createNestApplication(); 60 | await app.init(); 61 | }); 62 | 63 | afterAll(async () => { 64 | await app.close(); 65 | }); 66 | 67 | afterEach(() => { 68 | jest.clearAllMocks(); 69 | }); 70 | 71 | it('deleteFiles', async () => { 72 | const keyName = 'deleteFiles'; 73 | 74 | mockS3Send 75 | .mockResolvedValueOnce({ Contents: [{ Key: 'folder/file1.png' }] }) 76 | .mockResolvedValueOnce({}) 77 | .mockResolvedValueOnce({ Contents: [{ Key: 'folder/file2.png' }] }) 78 | .mockResolvedValueOnce({}); 79 | 80 | const gqlQuery = { 81 | query: ` 82 | mutation ($keys: [String!]!) { 83 | ${keyName}(keys: $keys) 84 | } 85 | `, 86 | variables: { 87 | keys: [ 88 | 'https://test-bucket.s3.amazonaws.com/folder/file1.png', 89 | 'https://test-bucket.s3.amazonaws.com/folder/file2.png', 90 | ], 91 | }, 92 | }; 93 | 94 | await request(app.getHttpServer()) 95 | .post('/graphql') 96 | .send(gqlQuery) 97 | .set('Content-Type', 'application/json') 98 | .expect(HttpStatus.OK) 99 | .expect(({ body: { data } }) => { 100 | expect(data[keyName]).toBe(true); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/upload/upload.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import type { FileUpload } from 'graphql-upload/processRequest.mjs'; 4 | import { Readable } from 'stream'; 5 | 6 | import { 7 | MockService, 8 | MockServiceFactory, 9 | } from 'src/common/factory/mockFactory'; 10 | 11 | import { UploadResolver } from './upload.resolver'; 12 | import { UploadService } from './upload.service'; 13 | 14 | describe('UploadResolver', () => { 15 | let resolver: UploadResolver; 16 | let mockedService: MockService; 17 | 18 | const mockFileUpload: FileUpload = { 19 | filename: 'test-file.png', 20 | mimetype: 'image/png', 21 | encoding: '7bit', 22 | createReadStream: () => Readable.from(Buffer.from('test')), 23 | }; 24 | 25 | beforeAll(async () => { 26 | const { UploadResolver } = await import('./upload.resolver'); 27 | 28 | const module: TestingModule = await Test.createTestingModule({ 29 | providers: [ 30 | UploadResolver, 31 | { 32 | provide: UploadService, 33 | useFactory: MockServiceFactory.getMockService(UploadService), 34 | }, 35 | ], 36 | }).compile(); 37 | 38 | resolver = module.get(UploadResolver); 39 | mockedService = module.get>(UploadService); 40 | }); 41 | 42 | afterEach(() => { 43 | jest.resetAllMocks(); 44 | }); 45 | 46 | it('Calling "uploadFile" method', async () => { 47 | const key = 'someFolderName/2024-01-01T00:00:00.000Z_test-file.png'; 48 | const expectedUrl = `https://test-bucket.s3.amazonaws.com/${key}`; 49 | 50 | mockedService.uploadFileToS3.mockResolvedValue({ key }); 51 | mockedService.getLinkByKey.mockReturnValue(expectedUrl); 52 | 53 | const result = await resolver.uploadFile(mockFileUpload); 54 | 55 | expect(result).toEqual(expectedUrl); 56 | expect(mockedService.uploadFileToS3).toHaveBeenCalledWith({ 57 | folderName: 'someFolderName', 58 | file: mockFileUpload, 59 | }); 60 | expect(mockedService.getLinkByKey).toHaveBeenCalledWith(key); 61 | }); 62 | 63 | it('Calling "uploadFiles" method', async () => { 64 | const files: FileUpload[] = [mockFileUpload, mockFileUpload]; 65 | const key1 = 'someFolderName/2024-01-01T00:00:00.000Z_test-file-1.png'; 66 | const key2 = 'someFolderName/2024-01-01T00:00:00.000Z_test-file-2.png'; 67 | const expectedUrl1 = `https://test-bucket.s3.amazonaws.com/${key1}`; 68 | const expectedUrl2 = `https://test-bucket.s3.amazonaws.com/${key2}`; 69 | 70 | mockedService.uploadFileToS3 71 | .mockResolvedValueOnce({ key: key1 }) 72 | .mockResolvedValueOnce({ key: key2 }); 73 | mockedService.getLinkByKey 74 | .mockReturnValueOnce(expectedUrl1) 75 | .mockReturnValueOnce(expectedUrl2); 76 | 77 | const result = await resolver.uploadFiles(files); 78 | 79 | expect(result).toEqual([expectedUrl1, expectedUrl2]); 80 | expect(mockedService.uploadFileToS3).toHaveBeenCalledTimes(2); 81 | }); 82 | 83 | it('Calling "deleteFiles" method', async () => { 84 | const keys = [ 85 | 'https://test-bucket.s3.amazonaws.com/folder/file1.png', 86 | 'https://test-bucket.s3.amazonaws.com/folder/file2.png', 87 | ]; 88 | 89 | mockedService.deleteS3Object.mockResolvedValue({ success: true }); 90 | 91 | const result = await resolver.deleteFiles(keys); 92 | 93 | expect(result).toBe(true); 94 | expect(mockedService.deleteS3Object).toHaveBeenCalledTimes(2); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | 5 | import * as bcrypt from 'bcrypt'; 6 | 7 | import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input'; 8 | import { 9 | CustomConflictException, 10 | CustomUnauthorizedException, 11 | } from 'src/common/exceptions'; 12 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 13 | import { UserRole } from 'src/user/entities/user.entity'; 14 | 15 | import { UserService } from '../user/user.service'; 16 | import { AccessTokenPayload } from './models/access-token.payload'; 17 | import { JwtWithUser } from './models/auth.model'; 18 | 19 | @Injectable() 20 | export class AuthService { 21 | constructor( 22 | private readonly userService: UserService, 23 | private readonly jwtService: JwtService, 24 | private readonly configService: ConfigService, 25 | ) {} 26 | 27 | private async generateRefreshToken(userId: string) { 28 | const refreshToken = this.jwtService.sign( 29 | { id: userId }, 30 | { 31 | secret: this.configService.get('JWT_REFRESH_TOKEN_PRIVATE_KEY'), 32 | expiresIn: '7d', 33 | }, 34 | ); 35 | await this.userService.update(userId, { refreshToken }); 36 | 37 | return refreshToken; 38 | } 39 | 40 | async verifyRefreshToken( 41 | userId: string, 42 | refreshToken: string, 43 | ): Promise { 44 | try { 45 | this.jwtService.verify(refreshToken, { 46 | secret: this.configService.get('JWT_REFRESH_TOKEN_PRIVATE_KEY'), 47 | }); 48 | 49 | return this.userService.doesExist({ id: userId, refreshToken }); 50 | } catch (err) { 51 | if (err.message === 'jwt expired') { 52 | this.userService.update(userId, { refreshToken: null }); 53 | } 54 | } 55 | } 56 | 57 | generateAccessToken(user: AccessTokenPayload, refreshToken: string) { 58 | return this.jwtService.sign({ 59 | ...user, 60 | refreshToken, 61 | }); 62 | } 63 | 64 | async signUp(input: SignUpInput): Promise { 65 | const doesExist = await this.userService.getOne({ 66 | where: { username: input.username }, 67 | }); 68 | 69 | if (doesExist) { 70 | throw new CustomConflictException({ property: 'username' }); 71 | } 72 | 73 | const user = await this.userService.create({ 74 | ...input, 75 | role: UserRole.USER, 76 | }); 77 | 78 | return this.signIn(user); 79 | } 80 | 81 | async signIn(user: AccessTokenPayload) { 82 | const refreshToken = await this.generateRefreshToken(user.id); 83 | const jwt = this.generateAccessToken(user, refreshToken); 84 | 85 | return { jwt, user }; 86 | } 87 | 88 | async validateUser(input: SignInInput): Promise { 89 | const { username, password } = input; 90 | 91 | const user = await this.userService.getOne({ 92 | where: { username }, 93 | select: { id: true, role: true, password: true }, 94 | }); 95 | 96 | if (!user) { 97 | throw new CustomUnauthorizedException(); 98 | } 99 | 100 | const isValid: boolean = await bcrypt.compare(password, user.password); 101 | 102 | if (!isValid) { 103 | throw new CustomUnauthorizedException(); 104 | } 105 | 106 | delete user.password; 107 | 108 | return user; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | 4 | import * as request from 'supertest'; 5 | 6 | import { AppModule } from 'src/app.module'; 7 | import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input'; 8 | import { UserRole } from 'src/user/entities/user.entity'; 9 | 10 | const TEST = 'test'; 11 | const userInfo = { 12 | username: TEST, 13 | nickname: TEST, 14 | }; 15 | 16 | const password = { 17 | password: TEST, 18 | }; 19 | 20 | describe('Container Test (e2e)', () => { 21 | let app: INestApplication; 22 | let savedJwt: string; 23 | 24 | beforeAll(async () => { 25 | const moduleFixture: TestingModule = await Test.createTestingModule({ 26 | imports: [AppModule], 27 | }).compile(); 28 | 29 | app = moduleFixture.createNestApplication(); 30 | await app.init(); 31 | }); 32 | 33 | afterAll(async () => { 34 | await app.close(); 35 | }); 36 | 37 | it('Health Check', () => { 38 | return request(app.getHttpServer()).get('/health').expect(HttpStatus.OK); 39 | }); 40 | 41 | it('Sign Up', async () => { 42 | const keyName = 'signUp'; 43 | 44 | const gqlQuery = { 45 | query: ` 46 | mutation ($input: ${SignUpInput.prototype.constructor.name}!) { 47 | ${keyName}(input: $input) { 48 | user{ 49 | role 50 | } 51 | } 52 | } 53 | `, 54 | variables: { 55 | input: { 56 | ...userInfo, 57 | ...password, 58 | }, 59 | }, 60 | }; 61 | 62 | await request(app.getHttpServer()) 63 | .post('/graphql') 64 | .send(gqlQuery) 65 | .set('Content-Type', 'application/json') 66 | .expect(HttpStatus.OK) 67 | .expect(({ body: { data } }) => { 68 | expect(data[keyName]).toMatchObject({ user: { role: UserRole.USER } }); 69 | }); 70 | }); 71 | 72 | it('Sign In', async () => { 73 | const keyName = 'signIn'; 74 | 75 | const gqlQuery = { 76 | query: ` 77 | mutation ($input: ${SignInInput.prototype.constructor.name}!) { 78 | ${keyName}(input: $input) { 79 | jwt 80 | } 81 | } 82 | `, 83 | variables: { 84 | input: { 85 | username: userInfo.username, 86 | ...password, 87 | }, 88 | }, 89 | }; 90 | 91 | await request(app.getHttpServer()) 92 | .post('/graphql') 93 | .send(gqlQuery) 94 | .set('Content-Type', 'application/json') 95 | .expect(HttpStatus.OK) 96 | .expect(({ body: { data } }) => { 97 | const { jwt } = data[keyName]; 98 | savedJwt = jwt; 99 | expect(data[keyName]).toHaveProperty('jwt'); 100 | }); 101 | }); 102 | 103 | it('Get Me', async () => { 104 | const keyName = 'getMe'; 105 | 106 | const gqlQuery = { 107 | query: ` 108 | query { 109 | ${keyName} { 110 | ${Object.keys(userInfo).join('\n')} 111 | } 112 | } 113 | `, 114 | }; 115 | 116 | await request(app.getHttpServer()) 117 | .post('/graphql') 118 | .send(gqlQuery) 119 | .set('Content-Type', 'application/json') 120 | .set('Authorization', 'Bearer ' + savedJwt) 121 | .expect(HttpStatus.OK) 122 | .expect(({ body: { data } }) => { 123 | expect(data[keyName]).toMatchObject(userInfo); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/common/graphql/customExtended.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FindManyOptions, 3 | FindOneOptions, 4 | FindOptionsOrder, 5 | Repository, 6 | } from 'typeorm'; 7 | 8 | import { CustomBadRequestException } from '../exceptions'; 9 | import { 10 | IDriection, 11 | IGetData, 12 | ISort, 13 | OneRepoQuery, 14 | RepoQuery, 15 | checkObject, 16 | directionObj, 17 | valueObj, 18 | } from './types'; 19 | import { processWhere } from './utils/processWhere'; 20 | 21 | const isObject = (value: unknown): boolean => { 22 | return typeof value === 'object' && !Array.isArray(value) && value !== null; 23 | }; 24 | 25 | type EmptyObject = { [K in keyof T]?: never }; 26 | type EmptyObjectOf = EmptyObject extends T ? EmptyObject : never; 27 | 28 | const isEmptyObject = ( 29 | value: T, 30 | ): value is EmptyObjectOf => { 31 | return Object.keys(value).length === 0; 32 | }; 33 | 34 | export function filterOrder( 35 | this: Repository, 36 | order: FindOptionsOrder, 37 | ) { 38 | Object.entries(order).forEach(([key, value]: [string, ISort]) => { 39 | if (!(key in this.metadata.propertiesMap)) { 40 | throw new CustomBadRequestException({ 41 | message: `Order key ${key} is not in ${this.metadata.name}`, 42 | }); 43 | } 44 | 45 | if (isObject(value)) { 46 | Object.entries(value).forEach(([_key, _value]) => { 47 | if (!directionObj[_key]) { 48 | throw new CustomBadRequestException({ 49 | message: `Order must be ${Object.keys(directionObj).join(' or ')}`, 50 | }); 51 | } 52 | if (!checkObject[_key].includes(_value as unknown)) { 53 | throw new CustomBadRequestException({ 54 | message: `Order ${_key} must be ${checkObject[_key].join(' or ')}`, 55 | }); 56 | } 57 | }); 58 | } else { 59 | if (!valueObj[value as IDriection]) { 60 | throw new CustomBadRequestException({ 61 | message: `Order must be ${Object.keys(valueObj).join(' or ')}`, 62 | }); 63 | } 64 | } 65 | }); 66 | } 67 | 68 | export class ExtendedRepository extends Repository { 69 | async getMany( 70 | this: Repository, 71 | option: RepoQuery = {}, 72 | dataType?: 'count' | 'data', 73 | ): Promise> { 74 | const { pagination, where, order, relations, select } = option; 75 | // You can remark these lines(if order {}) if you don't want to use strict order roles 76 | if (order) { 77 | filterOrder.call(this, order); 78 | } 79 | 80 | const condition: FindManyOptions = { 81 | relations, 82 | ...(select && { select }), 83 | ...(where && !isEmptyObject(where) && { where: processWhere(where) }), 84 | ...(order && { order }), 85 | ...(pagination && { 86 | skip: pagination.page * pagination.size, 87 | take: pagination.size, 88 | }), 89 | }; 90 | 91 | if (dataType === 'count') { 92 | return { count: await this.count(condition) }; 93 | } 94 | 95 | if (dataType === 'data') { 96 | return { data: await this.find(condition) }; 97 | } 98 | 99 | const [data, count] = await this.findAndCount(condition); 100 | return { data, count }; 101 | } 102 | 103 | async getOne( 104 | this: Repository, 105 | { where, relations, select }: OneRepoQuery, 106 | ): Promise { 107 | const condition: FindOneOptions = { 108 | relations, 109 | ...(select && { select }), 110 | ...(where && { where: processWhere(where) }), 111 | }; 112 | 113 | return await this.findOne(condition); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/upload/upload.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | import { 6 | DeleteObjectCommand, 7 | ListObjectsCommand, 8 | S3, 9 | } from '@aws-sdk/client-s3'; 10 | import { Upload } from '@aws-sdk/lib-storage'; 11 | import type { FileUpload } from 'graphql-upload/processRequest.mjs'; 12 | import * as path from 'path'; 13 | import { firstValueFrom } from 'rxjs'; 14 | 15 | import { CustomBadRequestException } from 'src/common/exceptions'; 16 | import { EnvironmentVariables } from 'src/common/helper/env.validation'; 17 | 18 | @Injectable() 19 | export class UploadService { 20 | private readonly awsS3: S3; 21 | public readonly S3_BUCKET_NAME: string; 22 | 23 | constructor( 24 | private readonly configService: ConfigService, 25 | private readonly httpService: HttpService, 26 | ) { 27 | this.awsS3 = new S3({ 28 | region: this.configService.get('AWS_S3_REGION'), 29 | credentials: { 30 | accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), 31 | secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), 32 | }, 33 | }); 34 | this.S3_BUCKET_NAME = this.configService.get('AWS_S3_BUCKET_NAME'); 35 | } 36 | 37 | getLinkByKey(key: string) { 38 | return `https://${this.configService.get( 39 | 'AWS_S3_BUCKET_NAME', 40 | )}.s3.amazonaws.com/${key}`; 41 | } 42 | 43 | async uploadFileToS3({ 44 | folderName, 45 | file, 46 | }: { 47 | folderName: string; 48 | file: FileUpload; 49 | }) { 50 | const key = `${folderName}/${new Date().toISOString()}_${path.basename( 51 | file.filename, 52 | )}`.replace(/ /g, ''); 53 | 54 | const upload = new Upload({ 55 | client: this.awsS3, 56 | params: { 57 | Bucket: this.S3_BUCKET_NAME, 58 | Key: key, 59 | Body: file.createReadStream(), 60 | ContentType: file.mimetype, 61 | }, 62 | }); 63 | 64 | try { 65 | await upload.done(); 66 | 67 | return { key }; 68 | } catch (error) { 69 | throw new CustomBadRequestException({ 70 | message: `File upload failed : ${error}`, 71 | }); 72 | } 73 | } 74 | 75 | async deleteS3Object(key: string): Promise<{ success: true }> { 76 | const command = new DeleteObjectCommand({ 77 | Bucket: this.S3_BUCKET_NAME, 78 | Key: key, 79 | }); 80 | 81 | const check = new ListObjectsCommand({ 82 | Bucket: this.S3_BUCKET_NAME, 83 | Prefix: key, 84 | }); 85 | 86 | const fileList = await this.awsS3.send(check); 87 | 88 | if (!fileList.Contents || fileList.Contents.length === 0) { 89 | throw new CustomBadRequestException({ message: `File does not exist` }); 90 | } 91 | 92 | try { 93 | await this.awsS3.send(command); 94 | return { success: true }; 95 | } catch (error) { 96 | throw new CustomBadRequestException({ 97 | message: `Failed to delete file : ${error}`, 98 | }); 99 | } 100 | } 101 | 102 | async listS3Object(folder: string) { 103 | const command = new ListObjectsCommand({ 104 | Bucket: this.S3_BUCKET_NAME, 105 | Prefix: folder, 106 | }); 107 | 108 | const data = await this.awsS3.send(command); 109 | 110 | const promise = await Promise.all( 111 | data.Contents.map(async (v) => { 112 | const url = this.getLinkByKey(v.Key); 113 | 114 | const data = await firstValueFrom(this.httpService.get(url)); 115 | return data; 116 | }), 117 | ); 118 | 119 | return promise.map((v) => v.data); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/user/user.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { DataSource } from 'typeorm'; 4 | 5 | import { 6 | MockService, 7 | MockServiceFactory, 8 | } from 'src/common/factory/mockFactory'; 9 | import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input'; 10 | import { UtilModule } from 'src/common/util/util.module'; 11 | import { UtilService } from 'src/common/util/util.service'; 12 | 13 | import { User } from './entities/user.entity'; 14 | import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; 15 | import { UserResolver } from './user.resolver'; 16 | import { UserService } from './user.service'; 17 | 18 | describe('UserResolver', () => { 19 | let resolver: UserResolver; 20 | let mockedService: MockService; 21 | let utilService: UtilService; 22 | 23 | beforeAll(async () => { 24 | const module: TestingModule = await Test.createTestingModule({ 25 | imports: [UtilModule], 26 | providers: [ 27 | UserResolver, 28 | { 29 | provide: UserService, 30 | useFactory: MockServiceFactory.getMockService(UserService), 31 | }, 32 | { 33 | provide: DataSource, 34 | useValue: undefined, 35 | }, 36 | ], 37 | }).compile(); 38 | 39 | utilService = module.get(UtilService); 40 | resolver = module.get(UserResolver); 41 | mockedService = module.get>(UserService); 42 | }); 43 | 44 | afterEach(() => { 45 | jest.resetAllMocks(); 46 | }); 47 | 48 | it('Calling "Get many user list" method', () => { 49 | const condition: GetManyInput = { 50 | where: { id: utilService.getRandomUUID }, 51 | }; 52 | 53 | const option = { relations: undefined, select: undefined }; 54 | 55 | expect(resolver.getManyUserList(condition, option)).not.toEqual(null); 56 | expect(mockedService.getMany).toHaveBeenCalledWith({ 57 | ...condition, 58 | ...option, 59 | }); 60 | }); 61 | 62 | it('Calling "Get one user list" method', () => { 63 | const condition: GetOneInput = { 64 | where: { id: utilService.getRandomUUID }, 65 | }; 66 | 67 | const option = { relations: undefined, select: undefined }; 68 | 69 | expect(resolver.getOneUser(condition, option)).not.toEqual(null); 70 | expect(mockedService.getOne).toHaveBeenCalledWith({ 71 | ...condition, 72 | ...option, 73 | }); 74 | }); 75 | 76 | it('Calling "Create user" method', () => { 77 | const dto = new CreateUserInput(); 78 | 79 | expect(resolver.createUser(dto)).not.toEqual(null); 80 | expect(mockedService.create).toHaveBeenCalledWith(dto); 81 | }); 82 | 83 | it('Calling "Update user" method', () => { 84 | const id = utilService.getRandomUUID; 85 | const dto = new UpdateUserInput(); 86 | 87 | resolver.updateUser(id, dto); 88 | 89 | expect(mockedService.update).toHaveBeenCalledWith(id, dto); 90 | }); 91 | 92 | it('Calling "Delete user" method', () => { 93 | const id = utilService.getRandomUUID; 94 | 95 | resolver.deleteUser(id); 96 | 97 | expect(mockedService.delete).toHaveBeenCalledWith(id); 98 | }); 99 | 100 | it('Calling "Get Me" method', () => { 101 | const user = new User(); 102 | 103 | const condition: GetOneInput = { where: { id: user.id } }; 104 | 105 | const option = { relations: undefined, select: undefined }; 106 | 107 | expect(resolver.getMe(user, option)).not.toEqual(null); 108 | expect(mockedService.getOne).toHaveBeenCalledWith({ 109 | ...condition, 110 | ...option, 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/common/exceptions/exception.format.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, Logger } from '@nestjs/common'; 2 | 3 | import { GraphQLError } from 'graphql'; 4 | 5 | import { PRESERVED_STATUS_CODES } from './exception.constant'; 6 | import { 7 | isBaseException, 8 | isGraphqlOriginalError, 9 | isHttpException, 10 | } from './exception.util'; 11 | 12 | const logger = new Logger('GraphqlError'); 13 | 14 | const determineErrorCondition = (error: GraphQLError) => { 15 | if (isGraphqlOriginalError(error.extensions)) { 16 | return { 17 | errorStatus: HttpStatus.BAD_REQUEST, 18 | errorCode: error.extensions.code || 'BAD_REQUEST', 19 | }; 20 | } 21 | 22 | if (isBaseException(error.originalError)) { 23 | return { 24 | errorStatus: error.originalError.statusCode, 25 | errorCode: error.originalError.code, 26 | }; 27 | } 28 | 29 | return { 30 | errorStatus: HttpStatus.INTERNAL_SERVER_ERROR, 31 | errorCode: 'INTERNAL_SERVER_ERROR', 32 | }; 33 | }; 34 | 35 | const determineHttpStatus = ( 36 | graphqlError: GraphQLError, 37 | errorStatus: HttpStatus, 38 | ): HttpStatus => { 39 | if (isGraphqlOriginalError(graphqlError.extensions)) { 40 | return HttpStatus.BAD_REQUEST; 41 | } 42 | 43 | if (isHttpException(graphqlError.originalError)) { 44 | const originalStatus = graphqlError.originalError.getStatus(); 45 | if (PRESERVED_STATUS_CODES.includes(originalStatus)) { 46 | return originalStatus; 47 | } 48 | } 49 | 50 | if (errorStatus === HttpStatus.INTERNAL_SERVER_ERROR) { 51 | return HttpStatus.INTERNAL_SERVER_ERROR; 52 | } 53 | 54 | return HttpStatus.OK; 55 | }; 56 | 57 | const logError = (error: GraphQLError): void => { 58 | logger.error({ 59 | message: error.message, 60 | originalError: error.originalError, 61 | path: error.path, 62 | locations: error.locations, 63 | }); 64 | }; 65 | 66 | const handleInternalServerError = ( 67 | errorStatus: HttpStatus, 68 | _: GraphQLError, 69 | ): void => { 70 | if (errorStatus === HttpStatus.INTERNAL_SERVER_ERROR) { 71 | /** 72 | * @TODO 73 | * Implement sentry like monitoring tools 74 | */ 75 | } 76 | }; 77 | 78 | const formatError = ( 79 | error: GraphQLError, 80 | options: { 81 | setHttpStatus: (status: HttpStatus) => void; 82 | logError: () => void; 83 | captureUnexpectedException: () => void; 84 | }, 85 | ) => { 86 | const { errorStatus, errorCode } = determineErrorCondition(error); 87 | 88 | options.logError(); 89 | 90 | if (errorStatus === HttpStatus.INTERNAL_SERVER_ERROR) { 91 | options.captureUnexpectedException(); 92 | } 93 | 94 | const httpStatus = determineHttpStatus(error, errorStatus); 95 | options.setHttpStatus(httpStatus); 96 | 97 | return { 98 | message: error.message, 99 | locations: error.locations, 100 | path: error.path, 101 | extensions: { 102 | errorStatus, 103 | errorCode, 104 | ...error.extensions, 105 | }, 106 | }; 107 | }; 108 | 109 | export const errorFormatter = ( 110 | errors: ReadonlyArray, 111 | originalData: Record, 112 | ) => { 113 | let statusCode = HttpStatus.OK; 114 | 115 | const formattedErrors = errors.map((error) => 116 | formatError(error, { 117 | setHttpStatus: (status) => { 118 | if (status !== HttpStatus.OK) { 119 | statusCode = status; 120 | } 121 | }, 122 | logError: () => logError(error), 123 | captureUnexpectedException: () => 124 | handleInternalServerError(HttpStatus.INTERNAL_SERVER_ERROR, error), 125 | }), 126 | ); 127 | 128 | return { 129 | statusCode, 130 | response: { 131 | data: statusCode === HttpStatus.OK ? originalData : null, 132 | errors: formattedErrors, 133 | }, 134 | }; 135 | }; 136 | -------------------------------------------------------------------------------- /generator/templates/service.spec.hbs: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { {{pascalCase tableName}}Service } from './{{tableName}}.service' 3 | 4 | import { 5 | MockRepository, 6 | MockRepositoryFactory, 7 | } from 'src/common/factory/mockFactory'; 8 | import { getRepositoryToken } from '@nestjs/typeorm' 9 | import { {{pascalCase tableName}}Repository } from './{{tableName}}.repository' 10 | import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity' 11 | import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input' 12 | import { ExtendedRepository } from 'src/common/graphql/customExtended' 13 | import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types' 14 | import { UtilModule } from 'src/common/util/util.module'; 15 | import { UtilService } from 'src/common/util/util.service'; 16 | 17 | describe('{{pascalCase tableName}}Service', () => { 18 | let service: {{pascalCase tableName}}Service 19 | let mockedRepository: MockRepository> 20 | let utilService: UtilService; 21 | 22 | beforeAll(async () => { 23 | const module: TestingModule = await Test.createTestingModule({ 24 | imports: [UtilModule], 25 | providers: [ 26 | {{pascalCase tableName}}Service, 27 | { 28 | provide: getRepositoryToken({{pascalCase tableName}}Repository), 29 | useFactory: MockRepositoryFactory.getMockRepository({{pascalCase tableName}}Repository), 30 | }, 31 | ], 32 | }).compile() 33 | 34 | utilService = module.get(UtilService); 35 | service = module.get<{{pascalCase tableName}}Service>({{pascalCase tableName}}Service) 36 | mockedRepository = module.get>>( 37 | getRepositoryToken({{pascalCase tableName}}Repository),) 38 | }) 39 | 40 | afterEach(() => { 41 | jest.resetAllMocks() 42 | }) 43 | 44 | it('Calling "Get many" method', () => { 45 | const option: RepoQuery<{{pascalCase tableName}}> = { 46 | where: { 47 | {{#if (is "increment" idType)}} 48 | id: utilService.getRandomNumber(0,999999) 49 | {{else}} 50 | id: utilService.getRandomUUID 51 | {{/if}} 52 | }, 53 | } 54 | 55 | expect(service.getMany(option)).not.toEqual(null) 56 | expect(mockedRepository.getMany).toHaveBeenCalled() 57 | }) 58 | 59 | it('Calling "Get one" method', () => { 60 | const option: OneRepoQuery<{{pascalCase tableName}}> = { 61 | where: { 62 | {{#if (is "increment" idType)}} 63 | id: utilService.getRandomNumber(0,999999) 64 | {{else}} 65 | id: utilService.getRandomUUID 66 | {{/if}} 67 | }, 68 | } 69 | 70 | expect(service.getOne(option)).not.toEqual(null) 71 | expect(mockedRepository.getOne).toHaveBeenCalled() 72 | }) 73 | 74 | it('Calling "Create" method', () => { 75 | const dto = new Create{{pascalCase tableName}}Input() 76 | const {{tableName}} = mockedRepository.create(dto) 77 | 78 | expect(service.create(dto)).not.toEqual(null) 79 | expect(mockedRepository.create).toHaveBeenCalledWith(dto) 80 | expect(mockedRepository.save).toHaveBeenCalledWith({{tableName}}) 81 | }) 82 | 83 | it('Calling "Update" method', () => { 84 | {{#if (is "increment" idType)}} 85 | const id = utilService.getRandomNumber(0,999999); 86 | {{else}} 87 | const id = utilService.getRandomUUID; 88 | {{/if}} 89 | const dto = new Update{{pascalCase tableName}}Input() 90 | const {{tableName}} = mockedRepository.create(dto) 91 | 92 | service.update(id, dto) 93 | 94 | expect(mockedRepository.create).toHaveBeenCalledWith(dto) 95 | expect(mockedRepository.update).toHaveBeenCalledWith(id, {{tableName}}) 96 | }) 97 | 98 | it('Calling "Delete" method', () => { 99 | {{#if (is "increment" idType)}} 100 | const id = utilService.getRandomNumber(0,999999); 101 | {{else}} 102 | const id = utilService.getRandomUUID; 103 | {{/if}} 104 | 105 | service.delete(id) 106 | 107 | expect(mockedRepository.delete).toHaveBeenCalledWith({ id }) 108 | }) 109 | }) -------------------------------------------------------------------------------- /graphql-status-code.md: -------------------------------------------------------------------------------- 1 | # GraphQL Response Status Codes 2 | 3 | - [HTTP Status Codes](#http-status-codes) 4 | - [✅ 200 OK Responses](#200-ok-responses) 5 | - [Successful Request](#✅-successful-request) 6 | - [Partial Success](#⚠️-partial-success-some-data--some-errors) 7 | - [No Data (e.g., NotFoundErrorException)](#⚠️-no-data-eg-notfounderrorexception) 8 | - [❌ Non-200 Responses](#non-200-responses) 9 | - [Bad Request (Invalid JSON, Syntax Error, etc.)](#❌-bad-request-invalid-json-syntax-error-etc) 10 | - [Authentication/Authorization Failure](#❌-authenticationauthorization-failure) 11 | - [Unsupported Accept Header](#❌-unsupported-accept-header) 12 | - [Internal Server Error](#❌-internal-server-error) 13 | - [Summary Table](#summary-table) 14 | - [References](#references) 15 | 16 | ## HTTP Status Codes 17 | 18 | GraphQL generally follows standard HTTP status code conventions, with some GraphQL-specific nuances. 19 | 20 | ### 200 OK Responses 21 | 22 | #### ✅ Successful Request 23 | 24 | - **HTTP Status Code**: `200 OK` 25 | - **Data**: Not `null` 26 | - **Errors**: `N/A` 27 | 28 | #### ⚠️ Partial Success (Some Data & Some Errors) 29 | 30 | - **HTTP Status Code**: `200 OK` 31 | - **Data**: Not `null` 32 | - **Errors**: `Array` (length ≥ 1) 33 | 34 | #### ⚠️ No Data (e.g., NotFoundErrorException) 35 | 36 | When a GraphQL response includes only errors and no data, the [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationjson) mandates the use of `200 OK`. 37 | 38 | - **HTTP Status Code**: `200 OK` 39 | - **Data**: `null` 40 | - **Errors**: `Array` (length ≥ 1) 41 | 42 | ### Non-200(OK) Responses 43 | 44 | #### ❌ Bad Request (Invalid JSON, Syntax Error, etc.) 45 | 46 | Used when the GraphQL request is malformed or invalid. 47 | 48 | - **HTTP Status Code**: `400 Bad Request` 49 | - **Data**: `null` 50 | - **Errors**: `Array` (length = 1) 51 | 52 | ref: [built-in-error-codes](https://www.apollographql.com/docs/apollo-server/data/errors#built-in-error-codes) 53 | 54 | #### ❌ Authentication/Authorization Failure 55 | 56 | Used when the request is unauthenticated or the client lacks required permissions. 57 | 58 | - **HTTP Status Code**: `401 Unauthorized`, `403 Forbidden` 59 | - **Data**: `null` 60 | - **Errors**: `Array` (length = 1) 61 | 62 | #### ❌ Unsupported Accept Header 63 | 64 | Returned when the request’s `Accept` header does not include `application/graphql-response+json`. 65 | 66 | While most GraphQL errors are handled via a custom `formatError`, HTTP-level errors like `406 Not Acceptable` should be handled early (e.g., using middleware and apply globally). 67 | 68 | - **HTTP Status Code**: `406 Not Acceptable` 69 | - **Data**: `null` 70 | - **Errors**: `Array` (length = 1) 71 | 72 | #### ❌ Internal Server Error 73 | 74 | Used when an unexpected server-side error or unhandled exception occurs. 75 | 76 | - **HTTP Status Code**: `500 Internal Server Error` 77 | - **Data**: `null` 78 | - **Errors**: `Array` (length = 1) 79 | 80 | ## Summary Table 81 | 82 | | Scenario | HTTP Status | Data | Errors | 83 | | ----------------------------- | ----------- | ---- | ------ | 84 | | Success | 200 | ✅ | ❌ | 85 | | Partial Success | 200 | ✅ | ✅ | 86 | | No Data | 200 | ❌ | ✅ | 87 | | Invalid Request (Syntax, etc) | 400 | ❌ | ✅ | 88 | | Auth Failure | 401 / 403 | ❌ | ✅ | 89 | | Unsupported Accept Header | 406 | ❌ | ✅ | 90 | | Internal Server Error | 500 | ❌ | ✅ | 91 | 92 | ## References 93 | 94 | This error-handling approach follows the [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/), which is currently in draft status and subject to change. 95 | 96 | - [GraphQL over HTTP - Status Codes](https://graphql.org/learn/serving-over-http/#status-codes) 97 | - [GraphQL Over HTTP Specification (GitHub)](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md) 98 | - [GraphQL over HTTP (Draft)](https://graphql.github.io/graphql-over-http/draft/) 99 | -------------------------------------------------------------------------------- /generator/templates/resolver.spec.hbs: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { {{pascalCase tableName}}Resolver } from './{{tableName}}.resolver' 3 | import { 4 | MockService, 5 | MockServiceFactory, 6 | } from 'src/common/factory/mockFactory' 7 | import { {{pascalCase tableName}}Service } from './{{tableName}}.service' 8 | import { GetManyInput, GetOneInput } from 'src/common/graphql/custom.input' 9 | import { {{pascalCase tableName}} } from './entities/{{tableName}}.entity' 10 | import { UtilModule } from 'src/common/util/util.module'; 11 | import { UtilService } from 'src/common/util/util.service'; 12 | import { DataSource } from 'typeorm'; 13 | 14 | import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input' 15 | 16 | describe('{{pascalCase tableName}}Resolver', () => { 17 | let resolver: {{pascalCase tableName}}Resolver 18 | let mockedService: MockService<{{pascalCase tableName}}Service> 19 | let utilService: UtilService; 20 | 21 | beforeAll(async () => { 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [UtilModule], 24 | providers: [ 25 | {{pascalCase tableName}}Resolver, 26 | { 27 | provide: {{pascalCase tableName}}Service, 28 | useFactory: MockServiceFactory.getMockService({{pascalCase tableName}}Service), 29 | }, 30 | { 31 | provide: DataSource, 32 | useValue: undefined, 33 | }, 34 | ], 35 | }).compile() 36 | 37 | utilService = module.get(UtilService); 38 | resolver = module.get<{{pascalCase tableName}}Resolver>({{pascalCase tableName}}Resolver) 39 | mockedService = module.get>({{pascalCase tableName}}Service) 40 | }) 41 | 42 | afterEach(() => { 43 | jest.resetAllMocks() 44 | }) 45 | 46 | it('Calling "Get many {{tableName}} list" method', () => { 47 | const condition: GetManyInput<{{pascalCase tableName}}> = { 48 | where: { 49 | {{#if (is "increment" idType)}} 50 | id: utilService.getRandomNumber(0,999999) 51 | {{else}} 52 | id: utilService.getRandomUUID 53 | {{/if}} 54 | }, 55 | } 56 | 57 | const option = { relations: undefined, select: undefined }; 58 | 59 | expect(resolver.getMany{{pascalCase tableName}}List(condition, option)).not.toEqual(null) 60 | expect(mockedService.getMany).toHaveBeenCalledWith({ 61 | ...condition, 62 | ...option, 63 | }) 64 | }) 65 | 66 | it('Calling "Get one {{tableName}} list" method', () => { 67 | const condition: GetOneInput<{{pascalCase tableName}}> = { 68 | where: { 69 | {{#if (is "increment" idType)}} 70 | id: utilService.getRandomNumber(0,999999) 71 | {{else}} 72 | id: utilService.getRandomUUID 73 | {{/if}} 74 | }, 75 | } 76 | 77 | const option = { relations: undefined, select: undefined }; 78 | 79 | expect(resolver.getOne{{pascalCase tableName}}(condition, option)).not.toEqual(null) 80 | expect(mockedService.getOne).toHaveBeenCalledWith({ 81 | ...condition, 82 | ...option, 83 | }) 84 | }) 85 | 86 | it('Calling "Create {{tableName}}" method', () => { 87 | const dto = new Create{{pascalCase tableName}}Input() 88 | 89 | expect(resolver.create{{pascalCase tableName}}(dto)).not.toEqual(null) 90 | expect(mockedService.create).toHaveBeenCalledWith(dto) 91 | }) 92 | 93 | it('Calling "Update {{tableName}}" method', () => { 94 | {{#if (is "increment" idType)}} 95 | const id = utilService.getRandomNumber(0,999999); 96 | {{else}} 97 | const id = utilService.getRandomUUID; 98 | {{/if}} 99 | const dto = new Update{{pascalCase tableName}}Input() 100 | 101 | resolver.update{{pascalCase tableName}}(id, dto) 102 | 103 | expect(mockedService.update).toHaveBeenCalledWith(id, dto) 104 | }) 105 | 106 | it('Calling "Delete {{tableName}}" method', () => { 107 | {{#if (is "increment" idType)}} 108 | const id = utilService.getRandomNumber(0,999999); 109 | {{else}} 110 | const id = utilService.getRandomUUID; 111 | {{/if}} 112 | 113 | resolver.delete{{pascalCase tableName}}(id) 114 | 115 | expect(mockedService.delete).toHaveBeenCalledWith(id) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /src/common/graphql/utils/processWhere.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | import { 4 | Between, 5 | FindOperator, 6 | FindOptionsWhere, 7 | ILike, 8 | In, 9 | IsNull, 10 | LessThan, 11 | LessThanOrEqual, 12 | Like, 13 | MoreThan, 14 | MoreThanOrEqual, 15 | Not, 16 | } from 'typeorm'; 17 | 18 | import { IWhere, OperatorType } from './types'; 19 | 20 | const isArray = (value: unknown): value is unknown[] => { 21 | return Array.isArray(value); 22 | }; 23 | 24 | const isPlainObject = (value: unknown): value is Record => { 25 | return typeof value === 'object' && !isArray(value) && value !== null; 26 | }; 27 | 28 | const merge = (prev: T, next: K): T & K => { 29 | return { ...prev, ...next }; 30 | }; 31 | 32 | export function set(object: T, path: string, value: K): T & K { 33 | const keys = path.split('.'); 34 | const lastKey = keys.pop(); 35 | 36 | let target = object; 37 | for (const key of keys) { 38 | if (!target[key] || typeof target[key] !== 'object') { 39 | target[key] = {}; 40 | } 41 | target = target[key]; 42 | } 43 | 44 | target[lastKey] = value; 45 | return object as T & K; 46 | } 47 | 48 | function processOperator(prevKey: string, nextObject: OperatorType) { 49 | const key = Object.keys(nextObject)[0]; 50 | const value = nextObject[key]; 51 | 52 | const operatorMap = new Map>([ 53 | ['$eq', { [prevKey]: value }], 54 | ['$ne', { [prevKey]: Not(value) }], 55 | ['$lt', { [prevKey]: LessThan(value) }], 56 | ['$lte', { [prevKey]: LessThanOrEqual(value) }], 57 | ['$gt', { [prevKey]: MoreThan(value) }], 58 | ['$gte', { [prevKey]: MoreThanOrEqual(value) }], 59 | ['$in', { [prevKey]: In(value) }], 60 | ['$nIn', { [prevKey]: Not(In(value)) }], 61 | ['$contains', { [prevKey]: Like(`%${value}%`) }], 62 | ['$nContains', { [prevKey]: Not(Like(`%${value}%`)) }], 63 | ['$iContains', { [prevKey]: ILike(`%${value}%`) }], 64 | ['$nIContains', { [prevKey]: Not(ILike(`%${value}%`)) }], 65 | ['$null', { [prevKey]: IsNull() }], 66 | ['$nNull', { [prevKey]: Not(IsNull()) }], 67 | ['$between', { [prevKey]: Between(value[0], value[1]) }], 68 | ]); 69 | 70 | if (key.includes('$') && !operatorMap.has(key)) { 71 | throw new BadRequestException(`Invalid operator ${key} for ${prevKey}`); 72 | } 73 | 74 | if (operatorMap.has(key)) { 75 | return operatorMap.get(key); 76 | } 77 | 78 | return { [prevKey]: nextObject }; 79 | } 80 | 81 | function goDeep( 82 | filters: IWhere, 83 | keyStore: string[] = [], 84 | _original: IWhere, 85 | ) { 86 | // Check if "and" expression 87 | if (isPlainObject(filters) && Object.keys(filters).length > 1) { 88 | const array = Object.entries(filters).map(([key, value]) => { 89 | return goDeep({ [key]: value }, keyStore, {}); 90 | }); 91 | 92 | return array.reduce( 93 | (prev: Record, next: Record) => { 94 | return merge(prev, next); 95 | }, 96 | {}, 97 | ); 98 | } 99 | 100 | const thisKey = Object.keys(filters)[0]; 101 | let nextObject = filters[Object.keys(filters)[0]]; 102 | 103 | // Check if next item is typeorm find operator 104 | if (nextObject instanceof FindOperator) { 105 | return { [thisKey]: nextObject }; 106 | } 107 | 108 | // Check if this item is on bottom 109 | if (!isPlainObject(nextObject)) { 110 | // In case use null as value 111 | if (nextObject === null) { 112 | return { [thisKey]: IsNull() }; 113 | } 114 | 115 | nextObject = { $eq: nextObject }; 116 | } 117 | const valueOfNextObjet = Object.values(nextObject)[0]; 118 | 119 | // Check if next item is on bottom 120 | if ( 121 | !isPlainObject(valueOfNextObjet) && 122 | !(Object.keys(nextObject).length > 1) 123 | ) { 124 | const value = processOperator(thisKey, nextObject); 125 | 126 | if (keyStore.length) { 127 | set(_original, keyStore.join('.'), value); 128 | keyStore = []; 129 | return _original; 130 | } 131 | return { ..._original, ...value }; 132 | } 133 | 134 | // In case object is plain and need to go deep 135 | return goDeep(nextObject, [...keyStore, thisKey], _original); 136 | } 137 | 138 | export function processWhere( 139 | original: IWhere, 140 | ): FindOptionsWhere | FindOptionsWhere[] { 141 | // Check if "or" expression 142 | if (isArray(original)) { 143 | return original.map((where, i) => goDeep(where, [], original[i])); 144 | } 145 | 146 | return goDeep(original, [], original); 147 | } 148 | -------------------------------------------------------------------------------- /src/common/decorators/option.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | InternalServerErrorException, 4 | createParamDecorator, 5 | } from '@nestjs/common'; 6 | import { GqlExecutionContext } from '@nestjs/graphql'; 7 | 8 | import { parse, print } from 'graphql'; 9 | import { Repository } from 'typeorm'; 10 | 11 | import { set } from '../graphql/utils/processWhere'; 12 | import { 13 | AddKeyValueInObjectProps, 14 | GetInfoFromQueryProps, 15 | } from '../graphql/utils/types'; 16 | 17 | const DATA = 'data'; 18 | 19 | const addKeyValuesInObject = ({ 20 | stack, 21 | relations, 22 | select, 23 | expandRelation, 24 | }: AddKeyValueInObjectProps): GetInfoFromQueryProps => { 25 | if (stack.length) { 26 | let stackToString = stack.join('.'); 27 | 28 | if (stack.length && stack[0] === DATA) { 29 | if (stack[0] !== DATA || (stack.length === 1 && stack[0] === DATA)) { 30 | return { relations, select }; 31 | } 32 | stackToString = stackToString.replace(`${DATA}.`, ''); 33 | } 34 | 35 | if (expandRelation) { 36 | set(relations, stackToString, true); 37 | } 38 | 39 | set(select, stackToString, true); 40 | } 41 | 42 | return { relations, select }; 43 | }; 44 | 45 | export function getOptionFromGqlQuery( 46 | this: Repository, 47 | query: string, 48 | ): GetInfoFromQueryProps { 49 | const splitted = query.split('\n'); 50 | 51 | // Remove alias 52 | splitted.shift(); 53 | splitted.pop(); 54 | 55 | const stack = []; 56 | 57 | const regex = /[\s\{]/g; 58 | let lastMetadata = this.metadata; 59 | 60 | return splitted.reduce( 61 | (acc, line) => { 62 | const replacedLine = line.replace(regex, ''); 63 | 64 | if (line.includes('{')) { 65 | stack.push(replacedLine); 66 | const isFirstLineDataType = replacedLine === DATA; 67 | 68 | if (!isFirstLineDataType) { 69 | lastMetadata = lastMetadata.relations.find( 70 | (v) => v.propertyName === replacedLine, 71 | ).inverseEntityMetadata; 72 | } 73 | 74 | return addKeyValuesInObject({ 75 | stack, 76 | relations: acc.relations, 77 | select: acc.select, 78 | expandRelation: true, 79 | }); 80 | } else if (line.includes('}')) { 81 | const hasDataTypeInStack = stack.length && stack[0] === DATA; 82 | 83 | lastMetadata = 84 | stack.length < (hasDataTypeInStack ? 3 : 2) 85 | ? this.metadata 86 | : lastMetadata.relations.find( 87 | (v) => v.propertyName === stack[stack.length - 2], 88 | ).inverseEntityMetadata; 89 | 90 | stack.pop(); 91 | 92 | return acc; 93 | } 94 | 95 | const addedStack = [...stack, replacedLine]; 96 | 97 | if ( 98 | ![...lastMetadata.columns, ...lastMetadata.relations] 99 | .map((v) => v.propertyName) 100 | .includes(addedStack[addedStack.length - 1]) 101 | ) { 102 | return acc; 103 | } 104 | 105 | return addKeyValuesInObject({ 106 | stack: addedStack, 107 | relations: acc.relations, 108 | select: acc.select, 109 | }); 110 | }, 111 | { 112 | relations: {}, 113 | select: {}, 114 | }, 115 | ); 116 | } 117 | 118 | export const getCurrentGraphQLQuery = (ctx: GqlExecutionContext) => { 119 | const { fieldName, path } = ctx.getArgByIndex(3) as { 120 | fieldName: string; 121 | path: { key: string }; 122 | }; 123 | 124 | const query = ctx.getContext().req.body.query; 125 | const operationJson = print(parse(query)); 126 | const operationArray = operationJson.split('\n'); 127 | 128 | operationArray.shift(); 129 | operationArray.pop(); 130 | 131 | const firstLineFinder = operationArray.findIndex((v) => 132 | v.includes(fieldName === path.key ? fieldName : path.key + ':'), 133 | ); 134 | 135 | operationArray.splice(0, firstLineFinder); 136 | 137 | const stack = []; 138 | 139 | let depth = 0; 140 | 141 | for (const line of operationArray) { 142 | stack.push(line); 143 | if (line.includes('{')) { 144 | depth++; 145 | } else if (line.includes('}')) { 146 | depth--; 147 | } 148 | 149 | if (depth === 0) { 150 | break; 151 | } 152 | } 153 | 154 | return stack.join('\n'); 155 | }; 156 | 157 | export const GraphQLQueryToOption = () => 158 | createParamDecorator((_: unknown, context: ExecutionContext) => { 159 | const ctx = GqlExecutionContext.create(context); 160 | const request = ctx.getContext().req; 161 | const query = getCurrentGraphQLQuery(ctx); 162 | const repository: Repository = request.repository; 163 | 164 | if (!repository) { 165 | throw new InternalServerErrorException( 166 | "Repository not found in request, don't forget to use UseRepositoryInterceptor", 167 | ); 168 | } 169 | 170 | const queryOption: GetInfoFromQueryProps = getOptionFromGqlQuery.call( 171 | repository, 172 | query, 173 | ); 174 | 175 | return queryOption; 176 | })(); 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "MIT", 8 | "type": "commonjs", 9 | "lint-staged": { 10 | "**/*": "prettier --write --ignore-unknown" 11 | }, 12 | "scripts": { 13 | "prebuild": "rimraf dist", 14 | "build": "nest build", 15 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 16 | "start": "export NODE_ENV=production&& nest start", 17 | "start:dev": "export NODE_ENV=development&& nest start --watch", 18 | "start:debug": "nest start --debug --watch", 19 | "start:prod": "node dist/main", 20 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 21 | "lint:fix": "eslint -c ./eslint.config.mjs \"{src,test}/**/*.ts\" --fix", 22 | "test": "jest", 23 | "test:unit": "jest --testPathPattern='^(?!.*\\.integration\\.spec\\.ts$).*\\.spec\\.ts$'", 24 | "test:integration": "jest --testPathPattern='\\.integration\\.spec\\.ts$'", 25 | "test:watch": "jest --watch", 26 | "test:cov": "jest --coverage", 27 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 28 | "test:e2e": "rm -rf dist && jest --config ./test/jest-e2e.json", 29 | "test:e2e:docker": "rm -rf dist && docker-compose -f docker-compose.e2e.yml --env-file ./.test.env up --exit-code-from app", 30 | "g": "plop --plopfile ./generator/plopfile.mjs", 31 | "prepare": "husky", 32 | "typeorm": "export NODE_ENV=production&& ts-node ./node_modules/typeorm/cli.js -d ./src/common/config/ormconfig.ts", 33 | "migration:create": "typeorm-ts-node-commonjs migration:create", 34 | "migration:generate": "yarn typeorm migration:generate", 35 | "migration:show": "yarn typeorm migration:show", 36 | "migration:run": "yarn build && yarn typeorm migration:run", 37 | "migration:revert": "yarn typeorm migration:revert" 38 | }, 39 | "dependencies": { 40 | "@apollo/server": "^5.2.0", 41 | "@as-integrations/express5": "^1.1.2", 42 | "@aws-sdk/client-s3": "^3.943.0", 43 | "@aws-sdk/lib-storage": "^3.943.0", 44 | "@aws-sdk/types": "^3.936.0", 45 | "@nestjs/apollo": "^13.2.1", 46 | "@nestjs/axios": "^4.0.1", 47 | "@nestjs/cache-manager": "^3.0.1", 48 | "@nestjs/common": "^11.1.9", 49 | "@nestjs/config": "^4.0.2", 50 | "@nestjs/core": "^11.1.9", 51 | "@nestjs/graphql": "^13.2.0", 52 | "@nestjs/jwt": "^11.0.1", 53 | "@nestjs/passport": "^11.0.5", 54 | "@nestjs/platform-express": "^11.1.9", 55 | "@nestjs/terminus": "^11.0.0", 56 | "@nestjs/typeorm": "^11.0.0", 57 | "apollo-server-core": "^3.13.0", 58 | "apollo-server-express": "^3.13.0", 59 | "axios": "^1.13.2", 60 | "bcrypt": "^6.0.0", 61 | "cache-manager": "^7.2.5", 62 | "class-transformer": "^0.5.1", 63 | "class-validator": "^0.14.3", 64 | "graphql": "^16.12.0", 65 | "graphql-type-json": "^0.3.2", 66 | "graphql-upload": "17", 67 | "ora": "^9.0.0", 68 | "passport": "^0.7.0", 69 | "passport-jwt": "^4.0.1", 70 | "passport-local": "^1.0.0", 71 | "pg": "^8.16.3", 72 | "reflect-metadata": "^0.2.2", 73 | "rimraf": "^6.1.2", 74 | "rxjs": "^7.8.2", 75 | "typeorm": "^0.3.27", 76 | "uuid": "^13.0.0" 77 | }, 78 | "devDependencies": { 79 | "@eslint/eslintrc": "^3.3.3", 80 | "@eslint/js": "^9.39.1", 81 | "@nestjs/cli": "^11.0.14", 82 | "@nestjs/schematics": "^11.0.9", 83 | "@nestjs/testing": "^11.1.9", 84 | "@swc/cli": "^0.7.9", 85 | "@swc/core": "^1.15.3", 86 | "@swc/jest": "^0.2.39", 87 | "@trivago/prettier-plugin-sort-imports": "^6.0.0", 88 | "@types/bcrypt": "^6.0.0", 89 | "@types/express": "^5.0.6", 90 | "@types/graphql-upload": "^17.0.0", 91 | "@types/jest": "30.0.0", 92 | "@types/node": "^24.10.1", 93 | "@types/passport-jwt": "^4.0.1", 94 | "@types/passport-local": "^1.0.38", 95 | "@types/supertest": "^6.0.3", 96 | "@types/uuid": "^11.0.0", 97 | "@typescript-eslint/eslint-plugin": "^8.48.1", 98 | "@typescript-eslint/parser": "^8.48.1", 99 | "eslint": "^9.39.1", 100 | "eslint-config-prettier": "^10.1.8", 101 | "eslint-plugin-prettier": "^5.5.4", 102 | "globals": "^16.5.0", 103 | "husky": "^9.1.7", 104 | "jest": "^30.2.0", 105 | "pg-mem": "^3.0.5", 106 | "plop": "^4.0.4", 107 | "prettier": "^3.7.3", 108 | "source-map-support": "^0.5.21", 109 | "supertest": "^7.1.4", 110 | "ts-jest": "^29.4.6", 111 | "ts-loader": "^9.5.4", 112 | "ts-node": "^10.9.2", 113 | "tsconfig-paths": "^4.2.0", 114 | "typescript": "5.9.3" 115 | }, 116 | "jest": { 117 | "moduleFileExtensions": [ 118 | "js", 119 | "json", 120 | "ts" 121 | ], 122 | "rootDir": ".", 123 | "testRegex": ".*\\.spec\\.ts$", 124 | "transform": { 125 | "^.+\\.(t|j)s$": [ 126 | "ts-jest", 127 | "@swc/jest" 128 | ], 129 | "^.+\\.mjs$": "@swc/jest" 130 | }, 131 | "moduleDirectories": [ 132 | "node_modules", 133 | "src" 134 | ], 135 | "moduleNameMapper": { 136 | "./src/index-minimal": "./src/index-minimal", 137 | "^src/(.*)$": "/src/$1" 138 | }, 139 | "collectCoverageFrom": [ 140 | "**/*.(t|j)s" 141 | ], 142 | "coverageDirectory": "coverage", 143 | "testEnvironment": "node", 144 | "transformIgnorePatterns": [ 145 | "node_modules/(?!(uuid|graphql-upload)/)" 146 | ] 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /generator/plopfile.mjs: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import fs from 'fs'; 3 | import ora from 'ora'; 4 | import util from 'util'; 5 | 6 | const prettify = async () => { 7 | const spinner = ora( 8 | '🚀 Code formatting...It will take about 10seconds', 9 | ).start(); 10 | const promisedExec = util.promisify(exec); 11 | await promisedExec('yarn lint:fix'); 12 | spinner.succeed('🎉 Done!'); 13 | }; 14 | 15 | const __dirname = new URL('../', import.meta.url).pathname; 16 | const rootPath = __dirname + 'src'; 17 | 18 | const TABLE_NAME = 'tableName'; 19 | const TEST_NEEDED = 'testNeeded'; 20 | const CLOUMN_LIST = 'columnList'; 21 | const ID_TYPE = 'idType'; 22 | const COLUMN_NAME = 'columnName'; 23 | const COLUMN_TYPE = 'columnType'; 24 | const COLUMN_REQUIRED = 'columnRequired'; 25 | 26 | export default function generator(plop) { 27 | plop.addHelper('is', (v1, v2) => v1 === v2); 28 | plop.addHelper('isIn', (v1, v2) => v2.includes(v1)); 29 | plop.setGenerator('Table-generator', { 30 | description: 'Adds a new table', 31 | prompts: [ 32 | { 33 | type: 'input', 34 | name: TABLE_NAME, 35 | message: 'Table Name', 36 | validate: (input) => { 37 | const pageDir = rootPath + `/${input}`; 38 | if (fs.existsSync(pageDir)) { 39 | return `🚫 [${input}] already exists`; 40 | } 41 | 42 | return String(input).trim().length > 0 || `Table name is required`; 43 | }, 44 | }, 45 | { 46 | type: 'confirm', 47 | name: TEST_NEEDED, 48 | message: 'Do you want to create a jest?', 49 | default: true, 50 | }, 51 | { 52 | type: 'checkbox', 53 | name: CLOUMN_LIST, 54 | message: 'Please select a column to incluede.', 55 | choices: ['createdAt', 'updatedAt'], 56 | default: ['createdAt', 'updatedAt'], 57 | }, 58 | { 59 | type: 'list', 60 | name: ID_TYPE, 61 | message: 'Please select the format of the id.', 62 | choices: ['increment', 'uuid'], 63 | default: 'increment', 64 | }, 65 | { 66 | type: 'input', 67 | name: COLUMN_NAME, 68 | message: 'Please enter only one data column name to generate.', 69 | validate: (input) => { 70 | return String(input).trim().length > 0 || `A column is required`; 71 | }, 72 | }, 73 | { 74 | type: 'list', 75 | name: COLUMN_TYPE, 76 | message: 'Please decide the type of column you created.', 77 | choices: ['string', 'number', 'boolean'], 78 | default: 'string', 79 | }, 80 | { 81 | type: 'confirm', 82 | name: COLUMN_REQUIRED, 83 | message: 'The column you created is required?', 84 | default: true, 85 | }, 86 | ], 87 | actions: (data) => [ 88 | { 89 | type: 'add', 90 | path: `${rootPath}/{{${TABLE_NAME}}}/entities/{{${TABLE_NAME}}}.entity.ts`, 91 | templateFile: 'templates/entity.hbs', 92 | }, 93 | { 94 | type: 'add', 95 | path: `${rootPath}/{{${TABLE_NAME}}}/inputs/{{${TABLE_NAME}}}.input.ts`, 96 | templateFile: 'templates/input.hbs', 97 | }, 98 | { 99 | type: 'add', 100 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.module.ts`, 101 | templateFile: 'templates/module.hbs', 102 | }, 103 | { 104 | type: 'add', 105 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.resolver.ts`, 106 | templateFile: 'templates/resolver.hbs', 107 | }, 108 | { 109 | type: 'add', 110 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.service.ts`, 111 | templateFile: 'templates/service.hbs', 112 | }, 113 | { 114 | type: 'add', 115 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.repository.ts`, 116 | templateFile: 'templates/repository.hbs', 117 | }, 118 | { 119 | type: 'add', 120 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.module.integration.spec.ts`, 121 | templateFile: 'templates/module.integration.spec.hbs', 122 | ...(!data[TEST_NEEDED] && { 123 | skip: () => 'skipped', 124 | }), 125 | }, 126 | { 127 | type: 'add', 128 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.resolver.spec.ts`, 129 | templateFile: 'templates/resolver.spec.hbs', 130 | ...(!data[TEST_NEEDED] && { 131 | skip: () => 'skipped', 132 | }), 133 | }, 134 | { 135 | type: 'add', 136 | path: `${rootPath}/{{${TABLE_NAME}}}/{{${TABLE_NAME}}}.service.spec.ts`, 137 | templateFile: 'templates/service.spec.hbs', 138 | ...(!data[TEST_NEEDED] && { 139 | skip: () => 'skipped', 140 | }), 141 | }, 142 | { 143 | type: 'append', 144 | path: `${rootPath}/app.module.ts`, 145 | separator: '\n', 146 | pattern: "'@nestjs/apollo';", 147 | template: 148 | "import { {{ pascalCase tableName }}Module } from './{{ tableName }}/{{ tableName }}.module';", 149 | }, 150 | { 151 | type: 'append', 152 | path: `${rootPath}/app.module.ts`, 153 | separator: '\n', 154 | pattern: '@Module({\n imports: [', 155 | template: '{{ pascalCase tableName }}Module,', 156 | }, 157 | 158 | () => prettify(), 159 | ], 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /src/upload/upload.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | 5 | import { S3 } from '@aws-sdk/client-s3'; 6 | import { Upload } from '@aws-sdk/lib-storage'; 7 | import type { FileUpload } from 'graphql-upload/processRequest.mjs'; 8 | import { of } from 'rxjs'; 9 | import { Readable } from 'stream'; 10 | 11 | import { CustomBadRequestException } from 'src/common/exceptions'; 12 | 13 | import { UploadService } from './upload.service'; 14 | 15 | jest.mock('@aws-sdk/client-s3'); 16 | jest.mock('@aws-sdk/lib-storage'); 17 | 18 | describe('UploadService', () => { 19 | let service: UploadService; 20 | let mockS3Send: jest.Mock; 21 | let mockHttpService: { get: jest.Mock }; 22 | 23 | const mockConfigService = { 24 | get: jest.fn((key: string) => { 25 | const config = { 26 | AWS_S3_REGION: 'ap-northeast-2', 27 | AWS_S3_ACCESS_KEY: 'test-access-key', 28 | AWS_S3_SECRET_KEY: 'test-secret-key', 29 | AWS_S3_BUCKET_NAME: 'test-bucket', 30 | }; 31 | return config[key]; 32 | }), 33 | }; 34 | 35 | const mockFileUpload: FileUpload = { 36 | filename: 'test-file.png', 37 | mimetype: 'image/png', 38 | encoding: '7bit', 39 | createReadStream: () => Readable.from(Buffer.from('test')), 40 | }; 41 | 42 | beforeAll(async () => { 43 | mockS3Send = jest.fn(); 44 | (S3 as jest.Mock).mockImplementation(() => ({ 45 | send: mockS3Send, 46 | })); 47 | 48 | mockHttpService = { 49 | get: jest.fn(), 50 | }; 51 | 52 | const module: TestingModule = await Test.createTestingModule({ 53 | providers: [ 54 | UploadService, 55 | { 56 | provide: ConfigService, 57 | useValue: mockConfigService, 58 | }, 59 | { 60 | provide: HttpService, 61 | useValue: mockHttpService, 62 | }, 63 | ], 64 | }).compile(); 65 | 66 | service = module.get(UploadService); 67 | }); 68 | 69 | afterEach(() => { 70 | jest.clearAllMocks(); 71 | }); 72 | 73 | describe('getLinkByKey', () => { 74 | it('should return correct S3 URL', () => { 75 | const key = 'folder/test-file.png'; 76 | const result = service.getLinkByKey(key); 77 | 78 | expect(result).toBe( 79 | 'https://test-bucket.s3.amazonaws.com/folder/test-file.png', 80 | ); 81 | }); 82 | }); 83 | 84 | describe('uploadFileToS3', () => { 85 | it('should upload file successfully', async () => { 86 | const mockUploadDone = jest.fn().mockResolvedValue({}); 87 | (Upload as unknown as jest.Mock).mockImplementation(() => ({ 88 | done: mockUploadDone, 89 | })); 90 | 91 | const result = await service.uploadFileToS3({ 92 | folderName: 'testFolder', 93 | file: mockFileUpload, 94 | }); 95 | 96 | expect(result).toHaveProperty('key'); 97 | expect(result.key).toContain('testFolder/'); 98 | expect(result.key).toContain('test-file.png'); 99 | expect(mockUploadDone).toHaveBeenCalled(); 100 | }); 101 | 102 | it('should throw CustomBadRequestException on upload failure', async () => { 103 | (Upload as unknown as jest.Mock).mockImplementation(() => ({ 104 | done: jest.fn().mockRejectedValue(new Error('Upload failed')), 105 | })); 106 | 107 | await expect( 108 | service.uploadFileToS3({ 109 | folderName: 'testFolder', 110 | file: mockFileUpload, 111 | }), 112 | ).rejects.toThrow(CustomBadRequestException); 113 | }); 114 | }); 115 | 116 | describe('deleteS3Object', () => { 117 | it('should delete file successfully', async () => { 118 | mockS3Send 119 | .mockResolvedValueOnce({ Contents: [{ Key: 'folder/file.png' }] }) 120 | .mockResolvedValueOnce({}); 121 | 122 | const result = await service.deleteS3Object('folder/file.png'); 123 | 124 | expect(result).toEqual({ success: true }); 125 | expect(mockS3Send).toHaveBeenCalledTimes(2); 126 | }); 127 | 128 | it('should throw CustomBadRequestException when file does not exist', async () => { 129 | mockS3Send.mockResolvedValueOnce({ Contents: [] }); 130 | 131 | await expect( 132 | service.deleteS3Object('nonexistent/file.png'), 133 | ).rejects.toThrow(CustomBadRequestException); 134 | }); 135 | 136 | it('should throw CustomBadRequestException on delete failure', async () => { 137 | mockS3Send 138 | .mockResolvedValueOnce({ Contents: [{ Key: 'folder/file.png' }] }) 139 | .mockRejectedValueOnce(new Error('Delete failed')); 140 | 141 | await expect(service.deleteS3Object('folder/file.png')).rejects.toThrow( 142 | CustomBadRequestException, 143 | ); 144 | }); 145 | }); 146 | 147 | describe('listS3Object', () => { 148 | it('should list and fetch S3 objects', async () => { 149 | const mockContents = [ 150 | { Key: 'folder/file1.png' }, 151 | { Key: 'folder/file2.png' }, 152 | ]; 153 | 154 | mockS3Send.mockResolvedValueOnce({ Contents: mockContents }); 155 | mockHttpService.get 156 | .mockReturnValueOnce(of({ data: 'file1-content' })) 157 | .mockReturnValueOnce(of({ data: 'file2-content' })); 158 | 159 | const result = await service.listS3Object('folder'); 160 | 161 | expect(result).toEqual(['file1-content', 'file2-content']); 162 | expect(mockHttpService.get).toHaveBeenCalledTimes(2); 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/user/user.module.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 2 | import { HttpStatus, INestApplication } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { GraphQLModule } from '@nestjs/graphql'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | 8 | import GraphQLJSON from 'graphql-type-json'; 9 | import { join } from 'path'; 10 | // import { IBackup } from 'pg-mem'; 11 | import { DataType, newDb } from 'pg-mem'; 12 | import * as request from 'supertest'; 13 | import { DataSource } from 'typeorm'; 14 | import { v4 } from 'uuid'; 15 | 16 | import { AuthModule } from 'src/auth/auth.module'; 17 | import { formatError } from 'src/common/format/graphql-error.format'; 18 | import { GetOneInput } from 'src/common/graphql/custom.input'; 19 | import { GraphqlPassportAuthGuard } from 'src/common/guards/graphql-passport-auth.guard'; 20 | 21 | import { getEnvPath } from '../common/helper/env.helper'; 22 | import { UserRole } from './entities/user.entity'; 23 | import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; 24 | import { UserModule } from './user.module'; 25 | 26 | describe('UserModule', () => { 27 | // let backup: IBackup; 28 | let app: INestApplication; 29 | let savedId: string; 30 | 31 | beforeAll(async () => { 32 | const db = newDb({ 33 | autoCreateForeignKeyIndices: true, 34 | }); 35 | 36 | // To implement custom function 37 | db.public.registerFunction({ 38 | name: 'current_database', 39 | implementation: () => 'test', 40 | }); 41 | 42 | // To resolve swc compiler issue. If you don't use swc compiler, remove these lines 43 | // https://github.com/oguimbal/pg-mem/issues/380 44 | db.public.registerFunction({ 45 | name: 'obj_description', 46 | args: [DataType.text, DataType.text], 47 | returns: DataType.text, 48 | implementation: () => 'test', 49 | }); 50 | 51 | // To implement custom function 52 | db.public.registerFunction({ 53 | name: 'version', 54 | implementation: () => 'user', 55 | }); 56 | 57 | db.registerExtension('uuid-ossp', (schema) => { 58 | schema.registerFunction({ 59 | name: 'uuid_generate_v4', 60 | returns: DataType.uuid, 61 | implementation: v4, 62 | impure: true, 63 | }); 64 | }); 65 | 66 | const datasource = await db.adapters.createTypeormDataSource({ 67 | type: 'postgres', 68 | entities: [join(process.cwd(), 'src', '**', '*.entity.{ts,js}')], 69 | autoLoadEntities: true, 70 | }); 71 | 72 | await datasource.initialize(); 73 | 74 | await datasource.synchronize(); 75 | 76 | const module: TestingModule = await Test.createTestingModule({ 77 | imports: [ 78 | TypeOrmModule.forRoot(), 79 | ConfigModule.forRoot({ 80 | isGlobal: true, 81 | envFilePath: getEnvPath(process.cwd()), 82 | }), 83 | GraphQLModule.forRootAsync({ 84 | driver: ApolloDriver, 85 | useFactory: () => ({ 86 | context: ({ req }) => ({ req }), 87 | cache: 'bounded', 88 | formatError, 89 | resolvers: { JSON: GraphQLJSON }, 90 | autoSchemaFile: join(process.cwd(), 'test/graphql-schema.gql'), 91 | sortSchema: true, 92 | }), 93 | }), 94 | UserModule, 95 | AuthModule, 96 | ], 97 | }) 98 | .overrideProvider(DataSource) 99 | .useValue(datasource) 100 | .overrideGuard(GraphqlPassportAuthGuard) 101 | .useValue({ canActivate: () => true }) 102 | .compile(); 103 | 104 | app = module.createNestApplication(); 105 | 106 | await app.init(); 107 | 108 | // To make each test run independently, remove these comments below 109 | // backup = db.backup(); 110 | }); 111 | 112 | // afterEach(async () => { 113 | // backup.restore(); 114 | // }); 115 | 116 | afterAll(async () => { 117 | await app.close(); 118 | }); 119 | 120 | const created = { 121 | username: 'someusername', 122 | nickname: 'somenickname', 123 | role: UserRole.USER, 124 | }; 125 | 126 | it('create', async () => { 127 | const keyName = 'createUser'; 128 | 129 | const gqlQuery = { 130 | query: ` 131 | mutation ($input: ${CreateUserInput.prototype.constructor.name}!) { 132 | ${keyName}(input: $input) { 133 | id 134 | ${Object.keys(created).join('\n')} 135 | } 136 | } 137 | `, 138 | variables: { 139 | input: { ...created, password: 'somepassword' }, 140 | }, 141 | }; 142 | 143 | await request(app.getHttpServer()) 144 | .post('/graphql') 145 | .send(gqlQuery) 146 | .set('Content-Type', 'application/json') 147 | .expect(HttpStatus.OK) 148 | .expect(({ body: { data } }) => { 149 | const { id } = data[keyName]; 150 | savedId = id; 151 | expect(data[keyName]).toMatchObject(created); 152 | }); 153 | }); 154 | 155 | it('getMany', async () => { 156 | const keyName = 'getManyUserList'; 157 | 158 | const gqlQuery = { 159 | query: ` 160 | query { 161 | ${keyName}{ 162 | data { 163 | ${Object.keys(created).join('\n')} 164 | } 165 | } 166 | } 167 | `, 168 | }; 169 | 170 | await request(app.getHttpServer()) 171 | .post('/graphql') 172 | .send(gqlQuery) 173 | .set('Content-Type', 'application/json') 174 | .expect(HttpStatus.OK) 175 | .expect(({ body: { data } }) => { 176 | expect(data[keyName]).toMatchObject({ data: [created] }); 177 | }); 178 | }); 179 | 180 | it('getOne', async () => { 181 | const keyName = 'getOneUser'; 182 | 183 | const gqlQuery = { 184 | query: ` 185 | query ($input: ${GetOneInput.prototype.constructor.name}!) { 186 | ${keyName} (input:$input) { 187 | ${Object.keys(created).join('\n')} 188 | } 189 | } 190 | `, 191 | variables: { 192 | input: { 193 | where: created, 194 | }, 195 | }, 196 | }; 197 | 198 | await request(app.getHttpServer()) 199 | .post('/graphql') 200 | .send(gqlQuery) 201 | .set('Content-Type', 'application/json') 202 | .expect(HttpStatus.OK) 203 | .expect(({ body: { data } }) => { 204 | expect(data[keyName]).toMatchObject(created); 205 | }); 206 | }); 207 | 208 | it('update', async () => { 209 | const keyName = 'updateUser'; 210 | 211 | const gqlQuery = { 212 | query: ` 213 | mutation ($id: String!, $input: ${UpdateUserInput.prototype.constructor.name}!) { 214 | ${keyName}(id: $id, input: $input) 215 | } 216 | `, 217 | variables: { 218 | id: savedId, 219 | input: { 220 | username: 'changedusername', 221 | }, 222 | }, 223 | }; 224 | 225 | await request(app.getHttpServer()) 226 | .post('/graphql') 227 | .send(gqlQuery) 228 | .set('Content-Type', 'application/json') 229 | .expect(HttpStatus.OK) 230 | .expect(({ body: { data } }) => { 231 | expect(data[keyName].affected).toBe(1); 232 | }); 233 | }); 234 | 235 | it('delete', async () => { 236 | const keyName = 'deleteUser'; 237 | 238 | const gqlQuery = { 239 | query: ` 240 | mutation ($id: String!) { 241 | ${keyName}(id: $id) 242 | } 243 | `, 244 | variables: { 245 | id: savedId, 246 | }, 247 | }; 248 | 249 | await request(app.getHttpServer()) 250 | .post('/graphql') 251 | .send(gqlQuery) 252 | .set('Content-Type', 'application/json') 253 | .expect(HttpStatus.OK) 254 | .expect(({ body: { data } }) => { 255 | expect(data[keyName].affected).toBe(1); 256 | }); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /generator/templates/module.integration.spec.hbs: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DataType, newDb } from 'pg-mem'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { DataSource } from 'typeorm'; 5 | import { v4 } from 'uuid'; 6 | import { join } from 'path'; 7 | import { HttpStatus, INestApplication } from '@nestjs/common'; 8 | import { {{pascalCase tableName}}Module } from './{{tableName}}.module'; 9 | import * as request from 'supertest'; 10 | import { GraphQLModule } from '@nestjs/graphql'; 11 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 12 | import { ConfigModule } from '@nestjs/config'; 13 | import { getEnvPath } from '../common/helper/env.helper'; 14 | import { formatError } from 'src/common/format/graphql-error.format'; 15 | import GraphQLJSON from 'graphql-type-json'; 16 | import { GraphqlPassportAuthGuard } from 'src/common/guards/graphql-passport-auth.guard'; 17 | import { Create{{pascalCase tableName}}Input, Update{{pascalCase tableName}}Input } from './inputs/{{tableName}}.input'; 18 | import { GetOneInput } from 'src/common/graphql/custom.input'; 19 | 20 | describe('{{pascalCase tableName}}Module', () => { 21 | let app: INestApplication; 22 | {{#if (is "increment" idType)}} 23 | let savedId: number; 24 | {{else}} 25 | let savedId: string; 26 | {{/if}} 27 | 28 | beforeAll(async ()=>{ 29 | const db = newDb({ 30 | autoCreateForeignKeyIndices: true, 31 | }); 32 | 33 | db.public.registerFunction({ 34 | name: 'current_database', 35 | implementation: () => 'test', 36 | }); 37 | 38 | db.public.registerFunction({ 39 | name: 'obj_description', 40 | args: [DataType.text, DataType.text], 41 | returns: DataType.text, 42 | implementation: () => 'test', 43 | }); 44 | 45 | db.public.registerFunction({ 46 | name: 'version', 47 | implementation: () => '{{tableName}}', 48 | }); 49 | 50 | db.registerExtension('uuid-ossp', (schema) => { 51 | schema.registerFunction({ 52 | name: 'uuid_generate_v4', 53 | returns: DataType.uuid, 54 | implementation: v4, 55 | impure: true, 56 | }); 57 | }); 58 | 59 | const datasource = await db.adapters.createTypeormDataSource({ 60 | type: 'postgres', 61 | entities: [join(process.cwd(), 'src', '**', '*.entity.{ts,js}')], 62 | autoLoadEntities: true, 63 | }); 64 | 65 | await datasource.initialize(); 66 | 67 | await datasource.synchronize(); 68 | 69 | const module: TestingModule = await Test.createTestingModule({ 70 | imports: [ 71 | TypeOrmModule.forRoot(), 72 | ConfigModule.forRoot({ 73 | isGlobal: true, 74 | envFilePath: getEnvPath(process.cwd()), 75 | }), 76 | GraphQLModule.forRootAsync({ 77 | driver: ApolloDriver, 78 | useFactory: () => ({ 79 | context: ({ req }) => ({ req }), 80 | cache: 'bounded', 81 | formatError, 82 | resolvers: { JSON: GraphQLJSON }, 83 | autoSchemaFile: join(process.cwd(), 'test/graphql-schema.gql'), 84 | sortSchema: true, 85 | }), 86 | }), 87 | {{pascalCase tableName}}Module, 88 | ], 89 | }) 90 | .overrideProvider(DataSource) 91 | .useValue(datasource) 92 | .overrideGuard(GraphqlPassportAuthGuard) 93 | .useValue({ canActivate: () => true }) 94 | .compile(); 95 | 96 | app = module.createNestApplication(); 97 | 98 | await app.init(); 99 | }); 100 | 101 | afterAll(async () => { 102 | await app.close(); 103 | }); 104 | 105 | const created = { 106 | {{columnName}}: 107 | {{#if (is "string" columnType)}} 108 | 'sampleString' 109 | {{/if}} 110 | 111 | {{#if (is "number" columnType)}} 112 | 1 113 | {{/if}} 114 | 115 | {{#if (is "boolean" columnType)}} 116 | true 117 | {{/if}} 118 | 119 | }; 120 | it('create', async () => { 121 | const keyName = 'create{{pascalCase tableName}}'; 122 | 123 | const gqlQuery = { 124 | query: ` 125 | mutation ($input: ${Create{{pascalCase tableName}}Input.prototype.constructor.name}!) { 126 | ${keyName}(input: $input) { 127 | id 128 | ${Object.keys(created).join('\n')}, 129 | } 130 | } 131 | `, 132 | variables: { 133 | input: created, 134 | }, 135 | }; 136 | 137 | await request(app.getHttpServer()) 138 | .post('/graphql') 139 | .send(gqlQuery) 140 | .set('Content-Type', 'application/json') 141 | .expect(HttpStatus.OK) 142 | .expect(({ body: { data } }) => { 143 | const { id } = data[keyName]; 144 | {{#if (is "increment" idType)}} 145 | savedId = Number(id) 146 | {{else}} 147 | savedId = id 148 | {{/if}} 149 | expect(data[keyName]).toMatchObject(created); 150 | }); 151 | }); 152 | 153 | it('getMany', async () => { 154 | const keyName = 'getMany{{pascalCase tableName}}List'; 155 | 156 | const gqlQuery = { 157 | query: `, 158 | query { 159 | ${keyName}{, 160 | data { 161 | ${Object.keys(created).join('\n')}, 162 | } 163 | } 164 | } 165 | `, 166 | }; 167 | 168 | await request(app.getHttpServer()) 169 | .post('/graphql') 170 | .send(gqlQuery) 171 | .set('Content-Type', 'application/json') 172 | .expect(HttpStatus.OK) 173 | .expect(({ body: { data } }) => { 174 | expect(data[keyName]).toMatchObject({ data: [created] }); 175 | }); 176 | }); 177 | 178 | it('getOne', async () => { 179 | const keyName = 'getOne{{pascalCase tableName}}'; 180 | 181 | const gqlQuery = { 182 | query: `, 183 | query ($input: ${GetOneInput.prototype.constructor.name}!) {, 184 | ${keyName} (input:$input) {, 185 | ${Object.keys(created).join('\n')}, 186 | } 187 | } 188 | `, 189 | variables: { 190 | input: { 191 | where: created, 192 | }, 193 | }, 194 | }; 195 | 196 | await request(app.getHttpServer()) 197 | .post('/graphql') 198 | .send(gqlQuery) 199 | .set('Content-Type', 'application/json') 200 | .expect(HttpStatus.OK) 201 | .expect(({ body: { data } }) => { 202 | expect(data[keyName]).toMatchObject(created); 203 | }); 204 | }); 205 | 206 | it('update', async () => { 207 | const keyName = 'update{{pascalCase tableName}}'; 208 | 209 | const gqlQuery = { 210 | query: `, 211 | mutation ($id: 212 | {{#if (is "increment" idType)}} 213 | Float 214 | {{else}} 215 | String 216 | {{/if}} 217 | !, $input: ${Update{{pascalCase tableName}}Input.prototype.constructor.name}!) {, 218 | ${keyName}(id: $id, input: $input), 219 | } 220 | `, 221 | variables: { 222 | id: savedId, 223 | input: created 224 | }, 225 | }; 226 | 227 | await request(app.getHttpServer()) 228 | .post('/graphql') 229 | .send(gqlQuery) 230 | .set('Content-Type', 'application/json') 231 | .expect(HttpStatus.OK) 232 | .expect(({ body: { data } }) => { 233 | expect(data[keyName].affected).toBe(1); 234 | }); 235 | }); 236 | 237 | it('delete', async () => { 238 | const keyName = 'delete{{pascalCase tableName}}'; 239 | 240 | const gqlQuery = { 241 | query: `, 242 | mutation ($id: 243 | {{#if (is "increment" idType)}} 244 | Float 245 | {{else}} 246 | String 247 | {{/if}} 248 | !) { 249 | ${keyName}(id: $id), 250 | } 251 | `, 252 | variables: { 253 | id: savedId, 254 | }, 255 | }; 256 | 257 | await request(app.getHttpServer()) 258 | .post('/graphql') 259 | .send(gqlQuery) 260 | .set('Content-Type', 'application/json') 261 | .expect(HttpStatus.OK) 262 | .expect(({ body: { data } }) => { 263 | expect(data[keyName].affected).toBe(1); 264 | }); 265 | }); 266 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS/TypeORM/GraphQL/PostgreSQL 2 | 3 | NestJS boilerplate with TypeORM, GraphQL and PostgreSQL 4 | 5 | ## Table of Contents 6 | 7 | - [1. Open for Contribution](#1-open-for-contribution) 8 | 9 | - [2. Getting Started](#2-getting-started) 10 | - [2.1. Installation](#21-installation) 11 | - [2.2. Run](#22-run) 12 | 13 | - [3. Docker](#3-docker) 14 | - [3.1. Docker Compose Installation](#31-docker-compose-installation) 15 | - [3.2. Before Getting Started](#32-before-getting-started) 16 | - [3.3. Run](#33-run) 17 | - [3.4. Note](#34-note) 18 | - [3.5. Run Only Database (Local Dev)](#35-run-only-database-local-dev) 19 | 20 | - [4. NestJS](#4-nestjs) 21 | 22 | - [5. PostgreSQL Database](#5-postgresql-database) 23 | 24 | - [6. TypeORM](#6-typeorm) 25 | - [6.1. Migration Setup and Usage](#61-migration-setup-and-usage) 26 | 27 | - [7. GraphQL](#7-graphql) 28 | - [7.1. Protected Queries/Mutation By Role](#71-protected-queriesmutation-by-role) 29 | - [7.2. GraphQL Query To Select and Relations](#72-graphql-query-to-select-and-relations) 30 | - [7.3. Field-Level Permission](#73-field-level-permission) 31 | - [7.4. GraphQL Status Code](#74-graphql-status-code) 32 | 33 | - [8. Custom CRUD](#8-custom-crud) 34 | 35 | - [9. Code generator](#9-code-generator) 36 | 37 | - [10. Caching](#10-caching) 38 | - [10.1. How To Use](#101-how-to-use) 39 | 40 | - [11. TDD](#11-tdd) 41 | - [11.1. Introduction](#111-introduction) 42 | - [11.2. Before Getting Started](#112-before-getting-started) 43 | - [11.3. Unit Test (with mock)](#113-unit-test-with-mock) 44 | - [11.4. Integration Test (with in-memory DB)](#114-integration-test-with-in-memory-db) 45 | - [11.5. End To End Test (with docker)](#115-end-to-end-test-with-docker) 46 | 47 | - [12. CI](#12-ci) 48 | - [12.1. Github Actions](#121-github-actions) 49 | - [12.2. Husky v9](#122-husky-v9) 50 | 51 | - [13. SWC Compiler](#13-swc-compiler) 52 | - [13.1. SWC + Jest error resolution](#131-swc--jest-error-resolution) 53 | 54 | - [14. Todo](#14-todo) 55 | 56 | - [15. License](#15-license) 57 | 58 | ## 1. Open for Contribution 59 | 60 | Totally open for any Pull Request, please feel free to contribute in any ways. 61 | There can be errors related with type or something. It would be very helpful to me for you to fix these errors. 62 | 63 | ## 2. Getting Started 64 | 65 | ### 2.1. Installation 66 | 67 | Before you start, make sure you have a recent version of [NodeJS](http://nodejs.org/) environment _>=14.0_ with NPM 6 or Yarn. 68 | 69 | The first thing you will need is to install NestJS CLI. 70 | 71 | ```bash 72 | $ yarn -g @nestjs/cli 73 | ``` 74 | 75 | And do install the dependencies 76 | 77 | ```bash 78 | $ yarn install # or npm install 79 | ``` 80 | 81 | ### 2.2. Run 82 | 83 | for development 84 | 85 | ```bash 86 | $ yarn dev # or npm run dev 87 | ``` 88 | 89 | for production 90 | 91 | ```bash 92 | $ yarn build # or npm run build 93 | $ yarn start # or npm run start 94 | ``` 95 | 96 | or run with docker following below 97 | 98 | ## 3. Docker 99 | 100 | ### 3.1. Docker Compose Installation 101 | 102 | Download docker from [Official website](https://docs.docker.com/compose/install) 103 | 104 | ### 3.2. Before Getting Started 105 | 106 | Before running Docker, you need to create an env file named `.production.env`. 107 | The content should be modified based on `.example.env`. 108 | The crucial point is that DB_HOST must be set to 'postgres'. 109 | 110 | ### 3.3. Run 111 | 112 | Open terminal and navigate to project directory and run the following command. 113 | 114 | ```bash 115 | # Only for production 116 | $ docker compose --env-file ./.production.env up 117 | ``` 118 | 119 | ### 3.4. Note 120 | 121 | If you want to use docker, you have to set DB_HOST in .production.env to be `postgres`. 122 | The default set is `postgres` 123 | 124 | ### 3.5. Run Only Database (Local Dev) 125 | 126 | You can just create PostgreSQL by below code, sync with .development.env 127 | 128 | ```bash 129 | $ docker run -p 5432:5432 --name postgres -e POSTGRES_PASSWORD=1q2w3e4r -d postgres 130 | ``` 131 | 132 | ## 4. [NestJS](https://docs.nestjs.com/) 133 | 134 | Base NestJS, We like it 135 | 136 | ## 5. [PostgreSQL Database](https://www.postgresql.org/) 137 | 138 | We use PostgreSQL for backend database, The default database that will be used is named 'postgres' 139 | You have to have PostgreSQL Database server before getting started. 140 | You can use [Docker PostgreSQL](https://hub.docker.com/_/postgres) to have server easily 141 | 142 | ## 6. [TypeORM](https://typeorm.io/) 143 | 144 | We use [Nestjs/TypeORM](https://docs.nestjs.com/techniques/database) 145 | In this template, We've been trying not to use `Pure SQL` to make the most of TypeORM. 146 | 147 | ### 6.1. Migration Setup and Usage 148 | 149 | This project uses TypeORM's migration feature to manage database schema changes. Follow the steps below to generate and apply migrations. 150 | 151 | > **Note** 152 | > 153 | > 1. The custom `typeorm` command defined in `package.json` is configured for `NODE_ENV=production`. 154 | > 2. Migrations are intended for production use, while `typeorm synchronize` should be used for development purposes. 155 | > 3. You can see the detailed configuration code [here](/src/common/config/ormconfig.ts) 156 | > 4. As you can see from the configuration code, migration files must be located in the subdirectory of `/src/common/database/migrations/${name}`. 157 | 158 | #### 6.1.1. Generate a migration file 159 | 160 | To reflect new changes in the database, you need to first generate a migration file. 161 | 162 | ```bash 163 | yarn migration:generate ./src/common/database/migrations/init 164 | ``` 165 | 166 | you can change the name of migration by replacing `init` 167 | 168 | #### 6.1.2. Run the Migration 169 | 170 | To apply the generated migration to the database, run the following command: 171 | 172 | ```bash 173 | yarn migration:run 174 | ``` 175 | 176 | #### 6.1.3. Revert a Migration 177 | 178 | To roll back the last applied migration, use the following command: 179 | 180 | ```bash 181 | yarn migration:revert 182 | ``` 183 | 184 | #### 6.1.4. Check Migration Status 185 | 186 | To view the current status of your migrations, run: 187 | 188 | ```bash 189 | yarn migration:show 190 | ``` 191 | 192 | #### 6.1.5. Create Migration Command 193 | 194 | You can also directly create a migration file using the following `typeorm` command: 195 | 196 | ```bash 197 | yarn migration:create ./src/common/database/migrations/init 198 | ``` 199 | 200 | This command generates an empty migration file where you can manually add your schema changes. 201 | 202 | ## 7. [GraphQL](https://graphql.org/) 203 | 204 | ##### packages: graphql, apollo-server-express and @nestjs/graphql, [graphqlUpload](https://www.npmjs.com/package/graphql-upload) ... 205 | 206 | We use GraphQL in a Code First approach (our code will create the GraphQL Schemas). 207 | 208 | We don't use [swagger](https://docs.nestjs.com/openapi/introduction) now, but you can use this if you want to. 209 | You can see [playground](http://localhost:8000/graphql) 210 | 211 | We use Apollo Server Playground by default. If you'd prefer the original GraphQL Playground, enable it as follows: 212 | 213 | ```js 214 | // src/common/config/graphql-config.service.ts 215 | 216 | GraphQLModule.forRootAsync < 217 | ApolloDriverConfig > 218 | { 219 | ... 220 | createGqlOptions(): Promise | ApolloDriverConfig { 221 | ... 222 | playground: true, 223 | ... 224 | } 225 | ... 226 | }; 227 | ``` 228 | 229 | ### 7.1. Protected Queries/Mutation By Role 230 | 231 | Some of the GraphQL queries are protected by a NestJS Guard (`GraphqlPassportAuthGuard`) and requires you to be authenticated (and some also requires to have the Admin role). 232 | You can solve them with Sending JWT token in `Http Header` with the `Authorization`. 233 | 234 | ```json 235 | # Http Header 236 | { 237 | "Authorization": "Bearer TOKEN" 238 | } 239 | ``` 240 | 241 | #### 7.1.1. Example Of Some Protected GraphQL 242 | 243 | - getMe (must be authenticated) 244 | - All methods generated by the generator (must be authenticated and must be admin) 245 | 246 | ### 7.2. GraphQL Query To Select and Relations 247 | 248 | #### 7.2.1. Dynamic Query Optimization 249 | 250 | - Automatically maps GraphQL queries to optimized SELECT and JOIN clauses in TypeORM. 251 | 252 | - Ensures that only the requested fields and necessary relations are retrieved, reducing over-fetching and improving performance. 253 | 254 | - With using interceptor (name: `UseRepositoryInterceptor`) and paramDecorator (name: `GraphQLQueryToOption`) 255 | 256 | #### 7.2.2. How to use 257 | 258 | - You can find example code in [/src/user/user.resolver.ts](/src/user/user.resolver.ts) 259 | 260 | ### 7.3. Field-Level Permission 261 | 262 | The [permission guard](/src/common/decorators/query-guard.decorator.ts) is used to block access to specific fields in client requests. 263 | 264 | #### 7.3.1. Why it was created 265 | 266 | - In GraphQL, clients can request any field, which could expose sensitive information. This guard ensures that sensitive fields are protected. 267 | 268 | - It allows controlling access to specific fields based on the server's permissions. 269 | 270 | #### 7.3.2. How to use 271 | 272 | ```ts 273 | @Query(()=>Some) 274 | @UseQueryPermissionGuard(Some, { something: true }) 275 | async getManySomeList(){ 276 | return this.someService.getMany() 277 | } 278 | ``` 279 | 280 | With this API, if the client request includes the field "something," a `Forbidden` error will be triggered. 281 | 282 | #### 7.3.3. Note 283 | 284 | There might be duplicate code when using this guard alongside `other interceptors`(name: `UseRepositoryInterceptor`) in this boilerplate. In such cases, you may need to adjust the code to ensure compatibility. 285 | 286 | ### 7.4. GraphQL Status Code 287 | 288 | Based on the GraphQL status code standard, we write status codes accordingly. 289 | You can see more details [here](./graphql-status-code.md). 290 | 291 | ## 8. Custom CRUD 292 | 293 | To make most of GraphQL's advantage, We created its own api, such as GetMany or GetOne. 294 | We tried to make it as comfortable as possible, but if you find any mistakes or improvements, please point them out or promote them. 295 | 296 | You can see detail in folder [/src/common/graphql](/src/common/graphql) files 297 | 298 | ```js 299 | // query 300 | query($input:GetManyInput) { 301 | getManyPlaces(input:$input){ 302 | data{ 303 | id 304 | longitude 305 | count 306 | } 307 | } 308 | } 309 | ``` 310 | 311 | ```js 312 | // variables 313 | { 314 | input: { 315 | pagination: { 316 | size: 10, 317 | page: 0, // Started from 0 318 | }, 319 | order: { id: 'DESC' }, 320 | dataType: 'data', //all or count or data - default: all 321 | where: { 322 | id: 3, 323 | }, 324 | }, 325 | }; 326 | ``` 327 | 328 | You can see detail [here](./process-where.md). 329 | 330 | ## 9. Code generator 331 | 332 | There is [CRUD Generator in NestJS](https://docs.nestjs.com/recipes/crud-generator). 333 | In this repository, It has its own generator with [plopjs](https://plopjs.com/documentation/). 334 | You can use like below. 335 | 336 | ```bash 337 | $ yarn g 338 | ``` 339 | 340 | ## 10. Caching 341 | 342 | This project provides a custom decorator that makes it easy to implement method caching in NestJS applications. 343 | 344 | 1. **Caching Functionality**: Utilizes `DiscoveryService` and `MetadataScanner` to handle method caching automatically at runtime. 345 | 2. **Usage**: Designed for use with any provider. 346 | 3. **GraphQL Resolvers**: Resolvers are also part of providers, but due to GraphQL's internal logic, method overrides do not work. Therefore, the functionality has been replaced with an interceptor. 347 | 348 | ### 10.1. How To Use 349 | 350 | ```js 351 | @Injectable() 352 | export class ExampleService { 353 | @Cache(...) 354 | async exampleMethod(...args: unknown) { 355 | ... 356 | } 357 | } 358 | ``` 359 | 360 | You can find related codes [here](./src/cache/custom-cache.module.ts) 361 | 362 | ## 11. TDD 363 | 364 | ### 11.1. Introduction 365 | 366 | [`@nestjs/testing`](https://docs.nestjs.com/fundamentals/testing) = `supertest` + `jest` 367 | 368 | ### 11.2. Before Getting Started 369 | 370 | Before starting the test, you need to set at least jwt-related environment variables in an env file named `.test.env`. 371 | 372 | ### 11.3. Unit Test (with mock) 373 | 374 | Unit test(with jest mock) for services & resolvers (\*.service.spec.ts & \*.resolver.spec.ts) 375 | 376 | #### 11.3.1. Run 377 | 378 | ```bash 379 | $ yarn test:unit 380 | ``` 381 | 382 | ### 11.4. Integration Test (with in-memory DB) 383 | 384 | Integration test(with [pg-mem](https://github.com/oguimbal/pg-mem)) for modules (\*.module.spec.ts) 385 | 386 | #### 11.4.1. Run 387 | 388 | ```bash 389 | $ yarn test:integration 390 | ``` 391 | 392 | ### 11.5. End To End Test (with docker) 393 | 394 | E2E Test(with docker container) 395 | 396 | #### 11.5.1. Run 397 | 398 | ```bash 399 | $ yarn test:e2e:docker 400 | ``` 401 | 402 | ## 12. CI 403 | 404 | ### 12.1. Github Actions 405 | 406 | To ensure github actions execution, please set the 'ENV' variable within your github actions secrets as your .test.env configuration. 407 | 408 | **Note:** Github Actions does not recognize newline characters. Therefore, you must remove any newline characters from each environment variable value in your `.env` file, ensuring that the entire content is on a single line when setting the Secret. If you need to use an environment variable value that includes newline characters, encode the value using Base64 and store it in the Github Secret, then decode it within the workflow. 409 | 410 | ex) 411 | 412 | ```bash 413 | JWT_PRIVATE_KEY= -----BEGIN RSA PRIVATE KEY-----...MIIEogIBAAKCAQBZ...-----END RSA PRIVATE KEY----- 414 | ``` 415 | 416 | ### 12.2. [Husky v9](https://github.com/typicode/husky) 417 | 418 | #### 12.2.1 Before Getting Started 419 | 420 | ```bash 421 | $ yarn prepare 422 | ``` 423 | 424 | #### 12.2.2 Pre commit 425 | 426 | [You can check detail here](./.husky/pre-commit) 427 | 428 | Before commit, The pre-commit hooks is executed. 429 | 430 | Lint checks have been automated to run before a commit is made. 431 | 432 | If you want to add test before commit actions, you can add follow line in [pre-commit](./.husky/pre-commit) file. 433 | 434 | ```bash 435 | ... 436 | yarn test 437 | ... 438 | ``` 439 | 440 | #### 12.2.3. Pre push 441 | 442 | [You can check detail here](./.husky/pre-push) 443 | 444 | The pre-push hooks is executed before the push action. 445 | 446 | The default rule set in the pre-push hook is to prevent direct pushes to the main branch. 447 | 448 | If you want to enable this action, you should uncomment the lines in the pre push file. 449 | 450 | ## 13. [SWC Compiler](https://docs.nestjs.com/recipes/swc) 451 | 452 | [SWC](https://swc.rs/) (Speedy Web Compiler) is an extensible Rust-based platform that can be used for both compilation and bundling. Using SWC with Nest CLI is a great and simple way to significantly speed up your development process. 453 | 454 | ### 13.1. SWC + Jest error resolution 455 | 456 | After applying `SWC`, the following error was displayed in jest using an in-memory database (`pg-mem`): 457 | 458 | ```bash 459 | QueryFailedError: ERROR: function obj_description(regclass,text) does not exist 460 | HINT: 🔨 Please note that pg-mem implements very few native functions. 461 | 462 | 👉 You can specify the functions you would like to use via "db.public.registerFunction(...)" 463 | 464 | 🐜 This seems to be an execution error, which means that your request syntax seems okay, 465 | but the resulting statement cannot be executed → Probably not a pg-mem error. 466 | 467 | *️⃣ Failed SQL statement: SELECT "table_schema", "table_name", obj_description(('"' || "table_schema" || '"."' || "table_name" || '"')::regclass, 'pg_class') AS table_comment FROM "information_schema"."tables" WHERE ("table_schema" = 'public' AND "table_name" = 'user'); 468 | 469 | 👉 You can file an issue at https://github.com/oguimbal/pg-mem along with a way to reproduce this error (if you can), and the stacktrace: 470 | ``` 471 | 472 | `pg-mem` is a library designed to emulate `PostgreSQL`, however, it does not support all features, which is why the above error occurred. 473 | 474 | This error can be resolved by implementing or overriding existing functions. Below is the function implementation for the resolution. 475 | Related issues can be checked [here](https://github.com/oguimbal/pg-mem/issues/380). 476 | 477 | ```ts 478 | db.public.registerFunction({ 479 | name: 'obj_description', 480 | args: [DataType.text, DataType.text], 481 | returns: DataType.text, 482 | implementation: () => 'test', 483 | }); 484 | ``` 485 | 486 | ## 14. Todo 487 | 488 | - [x] TDD 489 | - [x] Unit Test (Use mock) 490 | - [x] Integration Test (Use in-memory DB) 491 | - [x] End To End Test (Use docker) 492 | 493 | - [x] CI 494 | - [x] Github actions 495 | - [x] husky 496 | 497 | - [x] GraphQL Upload 498 | - [x] Healthcheck 499 | - [x] Divide usefactory 500 | - [x] SWC Compiler 501 | - [x] Refresh Token 502 | - [x] Caching 503 | - [ ] Graphql Subscription 504 | - [x] Remove lodash 505 | - [ ] [CASL](https://docs.nestjs.com/security/authorization#integrating-casl) 506 | 507 | ## 15. License 508 | 509 | MIT 510 | --------------------------------------------------------------------------------