Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(backend): migrate to nestjs #68

Merged
merged 10 commits into from
Jun 3, 2024
Prev Previous commit
Next Next commit
chore(backend): setup and implement rest resources
  • Loading branch information
tericcabrel committed May 26, 2024
commit 1c1381783f9b511156bcf10d143356db812771e6
1 change: 0 additions & 1 deletion apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ ADMIN_PASSWORD=nwHSvXuQxjey43Bp
CONVERTKIT_API_KEY=convertKitApiKey
CONVERTKIT_FORM_ID=formId
CONVERTKIT_TAG_ID=tagId
REQUEST_TIMEOUT=30000
GITHUB_CLIENT_ID=<github_client_id_here>
GITHUB_CLIENT_SECRET=<github_client_secret_here>
WEB_APP_URL=http:https://localhost:7500
Expand Down
13 changes: 0 additions & 13 deletions apps/backend/src/app.controller.ts

This file was deleted.

4 changes: 2 additions & 2 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { DomainModule } from '@snipcode/domain';

import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EnvironmentVariables, validate } from './configs/environment';
import { AppController } from './features/app/app.controller';
import { AppService } from './features/app/app.service';
import { AuthFeatureModule } from './features/auth/auth.module';
import { FolderFeatureModule } from './features/folders/folder.module';
import { SnippetFeatureModule } from './features/snippets/snippet.module';
Expand Down
27 changes: 0 additions & 27 deletions apps/backend/src/app.service.ts

This file was deleted.

7 changes: 7 additions & 0 deletions apps/backend/src/configs/environment.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { z } from 'zod';

const EnvironmentVariablesSchema = z.object({
ADMIN_PASSWORD: z.string(),
APP_VERSION: z.string(),
CONVERTKIT_API_KEY: z.string(),
CONVERTKIT_FORM_ID: z.string(),
CONVERTKIT_TAG_ID: z.string(),
DATABASE_URL: z.string(),
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
HOST: z.string(),
INTROSPECTION_ENABLED: z.boolean({ coerce: true }),
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
PORT: z.number({ coerce: true }).min(7000).max(8000),
SENTRY_DSN: z.string(),
SENTRY_ENABLED: z.boolean({ coerce: true }),
SESSION_LIFETIME: z.number({ coerce: true }).min(1),
SNIPPET_RENDERER_API_URL: z.string(),
WEB_APP_URL: z.string(),
WEB_AUTH_ERROR_URL: z.string(),
WEB_AUTH_SUCCESS_URL: z.string(),
});

export type EnvironmentVariables = z.infer<typeof EnvironmentVariablesSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ describe('AppController', () => {
});

describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
it('should return "Hello from Snipcode!"', () => {
expect(appController.helloWorld()).toBe('Hello from Snipcode!');
});
});
});
11 changes: 11 additions & 0 deletions apps/backend/src/features/app/app.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Controller, Get } from '@nestjs/common';

@Controller()
export class AppController {
constructor() {}

@Get()
async helloWorld(): Promise<string> {
return 'Hello from Snipcode!';
}
}
28 changes: 28 additions & 0 deletions apps/backend/src/features/app/app.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RoleService, UserService } from '@snipcode/domain';

import { EnvironmentVariables } from '../../configs/environment';

@Injectable()
export class AppService implements OnModuleInit {
constructor(
private readonly configService: ConfigService<EnvironmentVariables, true>,
private readonly userService: UserService,
private readonly roleService: RoleService,
) {}

async onModuleInit(): Promise<void> {
await this.roleService.loadRoles();

const adminRole = await this.roleService.findByName('admin');

if (!adminRole) {
throw new Error('[Data Loader]: Role administrator not found');
}

const adminPassword = this.configService.get<string>('ADMIN_PASSWORD');

await this.userService.loadAdminUser(adminRole, adminPassword);
}
}
4 changes: 3 additions & 1 deletion apps/backend/src/features/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';

import { AuthResolvers } from './graphql/auth.resolvers';
import { AuthController } from './rest/auth.controller';
import { GithubService } from './services/github.service';

@Module({
providers: [AuthResolvers],
providers: [AuthResolvers, GithubService, AuthController],
})
export class AuthFeatureModule {}
87 changes: 87 additions & 0 deletions apps/backend/src/features/auth/rest/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Controller, Get, Logger, Query, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
CreateSessionInput,
CreateUserRootFolderInput,
FolderService,
RoleService,
SessionService,
UserService,
} from '@snipcode/domain';
import { addDayToDate, errors } from '@snipcode/utils';
import { Response } from 'express';

import { EnvironmentVariables } from '../../../configs/environment';
import { AUTH_SUCCESS_URL } from '../../../utils/constants';
import { GithubService } from '../services/github.service';

@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);

constructor(
private readonly configService: ConfigService<EnvironmentVariables, true>,
private readonly githubService: GithubService,
private readonly userService: UserService,
private readonly roleService: RoleService,
private readonly folderService: FolderService,
private readonly sessionService: SessionService,
) {}

@Get('github/callback')
async authenticateWithGitHub(@Query('code') requestToken: string, @Res() res: Response): Promise<void> {
const sessionLifetime = this.configService.get<number>('SESSION_LIFETIME');
const webAuthSuccessUrl = this.configService.get<string>('WEB_AUTH_SUCCESS_URL');
const webAuthErrorUrl = this.configService.get<string>('WEB_AUTH_ERROR_URL');

const authResponse = await this.githubService.requestAccessTokenFromCode(requestToken);

const { access_token } = authResponse.data;

const userResponse = await this.githubService.retrieveGitHubUserData(access_token);

const userExist = await this.userService.findByEmail(userResponse.data.email);

if (userExist) {
const sessionInput = new CreateSessionInput({
expireDate: addDayToDate(sessionLifetime),
userId: userExist.id,
});
const session = await this.sessionService.create(sessionInput);

const updateUserInput = this.githubService.generateUserUpdateInputFromGitHubData(userExist, userResponse.data);

await this.userService.update(userExist, updateUserInput);

return res.redirect(AUTH_SUCCESS_URL(webAuthSuccessUrl, session.token));
}

const roleUser = await this.roleService.findByName('user');

if (!roleUser) {
this.logger.error(`GitHub Authentication: ${errors.ROLE_USER_NOT_FOUND}`);

return res.redirect(webAuthErrorUrl);
}

const createUserInput = this.githubService.generateUserRegistrationInputFromGitHubData(
userResponse.data,
roleUser.id,
);

const createdUser = await this.userService.create(createUserInput);

const createUserRootFolderInput = new CreateUserRootFolderInput(createdUser.id);

await this.folderService.createUserRootFolder(createUserRootFolderInput);

const sessionInput = new CreateSessionInput({
expireDate: addDayToDate(sessionLifetime),
userId: createdUser.id,
});

const session = await this.sessionService.create(sessionInput);

return res.redirect(AUTH_SUCCESS_URL(webAuthSuccessUrl, session.token));
}
}
75 changes: 75 additions & 0 deletions apps/backend/src/features/auth/services/github.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CreateUserInput, UpdateUserInput, User } from '@snipcode/domain';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

import { EnvironmentVariables } from '../../../configs/environment';
import { GitHubUserResponse } from '../types';

const GITHUB_AUTH_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_API_USER_PROFILE_URL = 'https://api.github.com/user';

@Injectable()
export class GithubService {
private httpClient: AxiosInstance = axios.create();

constructor(private readonly configService: ConfigService<EnvironmentVariables, true>) {}

async requestAccessTokenFromCode(code: string) {
const authQueryObject = {
client_id: this.configService.get('GITHUB_CLIENT_ID'),
client_secret: this.configService.get('GITHUB_CLIENT_SECRET'),
code,
};
const requestConfig: AxiosRequestConfig = {
headers: {
accept: 'application/json',
},
};
const requestBody = {};

const authQueryString = new URLSearchParams(Object.entries(authQueryObject)).toString();

return this.httpClient.post(`${GITHUB_AUTH_URL}?${authQueryString}`, requestBody, requestConfig);
}

async retrieveGitHubUserData(accessToken: string) {
const requestConfig: AxiosRequestConfig = {
headers: {
Authorization: `token ${accessToken}`,
},
};

return this.httpClient.get<GitHubUserResponse>(GITHUB_API_USER_PROFILE_URL, requestConfig);
}

generateUserRegistrationInputFromGitHubData = (data: GitHubUserResponse, roleId: string): CreateUserInput => {
const { avatar_url, email, login, name } = data;

const createUserInput = new CreateUserInput({
email,
name,
oauthProvider: 'github',
pictureUrl: avatar_url,
roleId,
timezone: null,
username: login,
});

createUserInput.isEnabled = true;

return createUserInput;
};

generateUserUpdateInputFromGitHubData = (user: User, data: GitHubUserResponse): UpdateUserInput => {
const { avatar_url, name } = data;

return new UpdateUserInput({
name,
oauthProvider: 'github',
pictureUrl: avatar_url,
roleId: user.roleId,
timezone: user.timezone,
});
};
}
File renamed without changes.
28 changes: 28 additions & 0 deletions apps/backend/src/features/snippets/rest/snippet.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SnippetService } from '@snipcode/domain';
import { OEmbedResult, generateOembedMetadata } from '@snipcode/embed';

import { EnvironmentVariables } from '../../../configs/environment';

@Controller('snippets')
export class SnippetController {
constructor(
private readonly configService: ConfigService<EnvironmentVariables, true>,
private readonly snippetService: SnippetService,
) {}

@Get(':id/oembed')
async generateOembed(@Param('id') id: string): Promise<OEmbedResult> {
const snippet = await this.snippetService.findById(id);

return generateOembedMetadata({
snippet: {
id: snippet.id,
name: snippet.name,
},
snippetRendererURL: this.configService.get('SNIPPET_RENDERER_API_URL'),
webAppURL: this.configService.get('WEB_APP_URL'),
});
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/features/snippets/snippet.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';

import { SnippetResolvers } from './graphql/snippet.resolvers';
import { SnippetController } from './rest/snippet.controller';

@Module({
providers: [SnippetResolvers],
providers: [SnippetResolvers, SnippetController],
})
export class SnippetFeatureModule {}
9 changes: 8 additions & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Sentry from '@sentry/node';
import { AppModule } from './app.module';
import { ApplicationExceptionFilter } from './configs/exception.filter';
import { EnvironmentVariables } from './types/common';
import { CORS_APOLLO_STUDIO_URL } from './utils/constants';

const bootstrap = async () => {
const app = await NestFactory.create(AppModule);
Expand All @@ -16,9 +17,15 @@ const bootstrap = async () => {

Sentry.setupNestErrorHandler(app, new ApplicationExceptionFilter(httpAdapter));

const configService = app.get(ConfigService<EnvironmentVariables, true>);

app.enableCors({
credentials: true,
origin: [configService.get('WEB_APP_URL'), CORS_APOLLO_STUDIO_URL],
});

app.enableShutdownHooks();

const configService = app.get(ConfigService<EnvironmentVariables, true>);
const logger = new Logger('NestApplication');

const port = configService.get<number>('PORT');
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ export const NEWSLETTER_SUBSCRIBE_SUCCESS = 'Subscribed to the newsletter succes

export const AUTH_USER_NOT_FOUND = 'The authenticated user not found';
export const AUTH_USER_NOT_FOUND_CODE = 'AUTH_USER_NOT_FOUND';

export const AUTH_SUCCESS_URL = (webAuthSuccessUrl: string, sessionToken: string): string => {
return `${webAuthSuccessUrl}?token=${sessionToken}`;
};
Loading