084 Сервис конфигурации

Добавляем в приложение возможность читать файлы .env

npm i dotenv

Создаём файл конфигурации окружения .env

корень проекта > .env

SALT=10

Далее нужно создать интерфейс для получения значения из конфигурации окружения

config > config.service.interface.ts

export interface IConfigService {  
	get: (key: string) => string;  
}

И тут реализуем сам сервис по получению данных из файла конфига окружения

config > config.service.ts

import { IConfigService } from './config.service.interface';
import { config, DotenvConfigOutput, DotenvParseOutput } from 'dotenv';
import { inject, injectable } from 'inversify';
import { TYPES } from '../types';
import { ILogger } from '../logger/logger.interface';
 
@injectable()
export class ConfigService implements IConfigService {
	// это наш конфиг, который попадёт в класс
	private config: DotenvParseOutput;
 
	constructor(@inject(TYPES.ILogger) private logger: ILogger) {
		// тут мы запрашиваем отпаршенный конфиг .env
		const result: DotenvConfigOutput = config();
 
		// если ошибка, то выведем логгер
		if (result.error) {
			this.logger.error('[ConfigService] Не удалось прочитать файл конфигурации');
		} else {
			// если всё хорошо, то будем присваивать отпаршенный конфиг в наш конфиг
			// делаем жёсткий прокаст, так как присваивается result, а не отдельный от него parsed
			this.config = result.parsed as DotenvParseOutput;
 
			this.logger.log('[ConfigService] Конфигурация загружена');
		}
	}
 
	get(key: string): string {  
	   return this.config[key];  
	}
}

Добавляем новый символ тайп по нашему сервису

types.ts

export const TYPES = {
	Application: Symbol.for('Application'),
	ILogger: Symbol.for('ILogger'),
	UserController: Symbol.for('UserController'),
	UserService: Symbol.for('UserService'),
	ExeptionFilter: Symbol.for('ExeptionFilter'),
	ConfigService: Symbol.for('ConfigService'),
};

Инжектим сервис в приложение

app.ts

constructor(
	@inject(TYPES.ILogger) private logger: ILogger,
	@inject(TYPES.UserController) private userController: UserController,
	@inject(TYPES.ExeptionFilter) private exeptionFilter: IExeptionFilter,
	// добавляем ConfigService
	@inject(TYPES.ConfigService) private configService: IConfigService,
) {
	this.app = express();
	this.port = 8000;
}

И инжектим в сервис пользователя, так как в нём мы генерируем новый пароль (нам нужен тут параметр SALT)

users.service.ts

constructor(
	// добавляем ConfigService
	@inject(TYPES.ConfigService) private configService: IConfigService,
) {}

И тут же биндим наш новый сервис в контейнер модулей приложения

main.ts

export const appBindings = new ContainerModule((bind: interfaces.Bind) => {
	bind<ILogger>(TYPES.ILogger).to(LoggerService);
	bind<IExeptionFilter>(TYPES.ExeptionFilter).to(ExeptionFilter);
	bind<IUserController>(TYPES.UserController).to(UserController);
	bind<IUserService>(TYPES.UserService).to(UserService);
	bind<App>(TYPES.Application).to(App);
	//
	bind<IConfigService>(TYPES.ConfigService).to(ConfigService);
});

И тут мы видим, что у нас вызывается два разных инстанса одного класса, что может быть довольно опасно

Чтобы исправить проблему, нужно перевести конкретно данный класс в синглтон, что позволит нам инстанциировать ровно один раз одну копию класса (то есть, она всегда буде одинакова). Чтобы сделать это, нужно при биндинге указать дополнительным методом inSingletonScope(), что нам нужно иметь именно один конкретный инстанс класса

export const appBindings = new ContainerModule((bind: interfaces.Bind) => {
	bind<ILogger>(TYPES.ILogger).to(LoggerService).inSingletonScope();
	bind<IExeptionFilter>(TYPES.ExeptionFilter).to(ExeptionFilter);
	bind<IUserController>(TYPES.UserController).to(UserController);
	bind<IUserService>(TYPES.UserService).to(UserService);
	bind<IConfigService>(TYPES.ConfigService).to(ConfigService).inSingletonScope();
	bind<App>(TYPES.Application).to(App);
});

Тут меняем метод генерации пароля на получение соли (salt) извне

user.entity.ts

public async setPassword(pass: string, salt: number): Promise<void> {  
   this._password = await hash(pass, salt);  
}

А тут получаем из ConfigService через метод get определённое значение нашего .env файла, а конкретно параметра SALT. Далее новому пользователю устанавливаем пароль

users.service.ts

constructor(
	// добавляем ConfigService
	@inject(TYPES.ConfigService) private configService: IConfigService,
) {}
 
async createUser({ email, name, password }: UserRegisterDto): Promise<User | null> {
	const newUser = new User(email, name);
 
	// получаем соль для генерации пароля
	const salt = this.configService.get('SALT');
	await newUser.setPassword(password, Number(salt));
	console.log(salt);
 
	// проверка что он есть?
	// если есть - возвращаем null
	// если нет - создаём
	return null;
}

И теперь после отправки запроса на сервер, можно увидеть полученные данные из .env

085 Работа с prisma

Для написания запросов и построения моделей баз данных будет использоваться Prism ORM. Она позволяет прямо из кода приложения описать нашу базу данных и взаимодействовать с ней. Конкретно в курсе будет использоваться SQLite + Prism ORM

Первым делом, нужно установить призму

// устанавливаем призму
npm i -D prisma
 
// устанавливаем клиент призмы
npm i @prisma/client
 
// инициализируем призму
npx prisma init

Далее настроим плагин в VSCode для призмы

.vscode > settings.json

{
	"[typescript]": {
		"editor.defaultFormatter": "dbaeumer.vscode-eslint"
	},
	"editor.codeActionsOnSave": {
		"source.fixAll.eslint": true
	},
	"[prisma]": {
		"editor.defaultFormatter": "Prisma.prisma"
	},
}

После последней команды терминала у нас появляется папка prisma, в которой находится файл схемы призмы. Тут мы формируем саму модель работы призмы. Первым делом делаем коннект в db к нужной базе данных и к файлу, в которой будет храниться база. Далее нужно создать модель пользователя в UserModel, где укажем нужные поля под его данные

schema.prisma

// клиент генератора запросов
generator client {
    provider = "prisma-client-js"
}
 
// исходники для данных
datasource db {
    // тут нужно указать, к какой БД подключаемся
    provider = "sqlite"
    // это ссылка до файла базы данных
    // создастся автоматически при начале работы с призмой
    url      = "file:./dev.db"
}
 
// тут уже будет находиться само статическое описание модели пользователя
model UserModel {
    id       Int    @id @default(autoincrement())
    email    String
    password String
    name     String
}

Дальше произведём первую миграцию, при которой у нас создастся файл БД

npx prisma migrate dev

Каждой миграции даётся своё имя

И мы имеем примерно такую структуру после миграции:

Уберём базы и окружение из отслеживания гита (так как там может храниться важная информация)

.gitignore

/node_modules
/dist
/.clinic
/.env
/prisma/dev.db
/prisma/dev.db-journal

Так же отдельно вынесем команду, которая будет генерировать типы нашей сформированной базы данных

package.json

"scripts": {
	"generate": "prisma generate"
},

После срабатывания данной команды генерации типов, описанные в схеме модели конвертируются в типы, которые мы можем использовать в проекте

Сейчас нам нужно привязать сервис призмы к нашему приложению через DI

types.ts

export const TYPES = {
	Application: Symbol.for('Application'),
	ILogger: Symbol.for('ILogger'),
	UserController: Symbol.for('UserController'),
	UserService: Symbol.for('UserService'),
	ExeptionFilter: Symbol.for('ExeptionFilter'),
	ConfigService: Symbol.for('ConfigService'),
	//
	PrismaService: Symbol.for('PrismaService'),
};

main.ts

export const appBindings = new ContainerModule((bind: interfaces.Bind) => {
	bind<ILogger>(TYPES.ILogger).to(LoggerService).inSingletonScope();
	bind<IExeptionFilter>(TYPES.ExeptionFilter).to(ExeptionFilter);
	bind<IUserController>(TYPES.UserController).to(UserController);
	bind<IUserService>(TYPES.UserService).to(UserService);
	// биндим призму, используя синглтон
	bind<PrismaService>(TYPES.PrismaService).to(PrismaService).inSingletonScope();
	bind<IConfigService>(TYPES.ConfigService).to(ConfigService).inSingletonScope();
	bind<App>(TYPES.Application).to(App);
});

app.ts

constructor(
	@inject(TYPES.ILogger) private logger: ILogger,
	@inject(TYPES.UserController) private userController: UserController,
	@inject(TYPES.ExeptionFilter) private exeptionFilter: IExeptionFilter,
	@inject(TYPES.ConfigService) private configService: IConfigService,
	// инжектим инстанс призмы к приложению
	@inject(TYPES.PrismaService) private prismaService: PrismaService,
) {
	this.app = express();
	this.port = 8000;
}
 
public async init(): Promise<void> {
	this.useMiddleware();
	this.useRoutes();
	this.useExeptionFilters();
 
	// подключаемся асинхронно к сервису призмы
	await this.prismaService.connect();
	
	this.server = this.app.listen(this.port);
	this.logger.log(`Сервер запущен на http://localhost:${this.port}`);
}

И тут уже представлена сама реализация функции нашего сервиса по работе с призмой

database > prisma.service.ts

import { PrismaClient, UserModel } from '@prisma/client';
import { inject, injectable } from 'inversify';
import { TYPES } from '../types';
import { ILogger } from '../logger/logger.interface';
 
@injectable()
export class PrismaService {
	// клиент призмы
	client: PrismaClient;
 
	// инстанциируем сам клиент призмы
	constructor(@inject(TYPES.ILogger) private logger: ILogger) {
		this.client = new PrismaClient();
	}
 
	// метод подключения к базе данных
	async connect(): Promise<void> {
		try {
			await this.client.$connect();
			this.logger.log('[PrismaService] совершено успешное подключение к базе данных');
		} catch (e: unknown) {
			if (e instanceof Error) {
				this.logger.log(`[PrismaService] не удалось подключиться к базе данных ${e.message}`);
			}
		}
	}
 
	// метод отключения от базы данных
	async disconnect(): Promise<void> {
		await this.client.$disconnect();
		this.logger.log('[PrismaService] совершено отключение от базе данных');
	}
}

Лог о подключении к базе данных

086 Репозиторий users

Далее нам нужно реализовать репозиторий, который будет общаться с базой данных, записывать новых и получать старых пользователей системы.

Первым делом, нам нужно реализовать интерфейс, который будет представлять наш репозиторий. Метод create на вход получает определённую entity и на выходе через определённый промежуток получает модель из базы. Метод find будет искать пользователя по его почте.

users.repository.interface.ts

import { UserModel } from '@prisma/client';
import { User } from './user.entity';
 
export interface IUsersRepository {
	create: (user: User) => Promise<UserModel>;
	find: (email: string) => Promise<UserModel | null>;
}

И далее тут реализуем из интерфейса саму работу с БД через репозиторий

users.repository.ts

import { IUsersRepository } from './users.repository.interface';
import { User } from './user.entity';
import { UserModel } from '@prisma/client';
import { inject, injectable } from 'inversify';
import { TYPES } from '../types';
import { PrismaService } from '../database/prisma.service';
 
@injectable()
export class UsersRepository implements IUsersRepository {
	// тут мы добавляем сервис призмы
	constructor(@inject(TYPES.PrismaService) private prismaService: PrismaService) {}
 
	// это метод создания нового пользователя
	// так как мы сюда передаём пользователя, то его сразу можно и деструктуризировать
	async create({ email, password, name }: User): Promise<UserModel> {
		return this.prismaService.client.userModel.create({
			data: {
				email,
				name,
				password,
			},
		});
	}
 
	// тут будет осуществляться поиск пользователя
	async find(email: string): Promise<UserModel | null> {
		return this.prismaService.client.userModel.findFirst({
			where: {
				email,
			},
		});
	}
}

Так же нужно сказать, что мы можем выполнять над моделью пользователя все те действия, которые нам могут потребоваться: обновить, удалить, добавить, посчитать и так далее

Ну и сейчас нужно забиндить наш UsersRepository в DI, чтобы иметь доступ к инстансу класса при инициализации в конструкторе

export const TYPES = {
	// code ...
	UsersRepository: Symbol.for('UsersRepository'),
};
export const appBindings = new ContainerModule((bind: interfaces.Bind) => {
	// code ...
	bind<IUsersRepository>(TYPES.UsersRepository).to(UsersRepository).inSingletonScope();
});

Далее же нам требуется изменить логику работы UserService, чтобы он возвращал не пользователя, а модель пользователя из базы данных

export interface IUserService {
	createUser: (dto: UserRegisterDto) => Promise<UserModel | null>;
	validateUser: (dto: UserLoginDto) => Promise<boolean>;
}

Тут уже при отправке запроса на сервер, мы сначала проверяем в existedUser, что у нас нет данного пользователя в базе, и если его нет, то в конечном return вызываем метод create() для создания нового пользователя

users.service.ts

@injectable()
export class UserService implements IUserService {
	constructor(
		@inject(TYPES.ConfigService) private configService: IConfigService,
		// инжектим инстанс репозитория пользователя
		@inject(TYPES.UsersRepository) private usersRepository: IUsersRepository,
	) {}
 
	async createUser({ email, name, password }: UserRegisterDto): Promise<UserModel | null> {
		const newUser = new User(email, name);
		const salt = this.configService.get('SALT');
		await newUser.setPassword(password, Number(salt));
 
		// ищем пользователя по почте
		const existedUser = await this.usersRepository.find(email);
		// если есть - возвращаем null
		if (existedUser) {
			return null;
		}
		// если нет - создаём
		return this.usersRepository.create(newUser);
	}
 
	async validateUser(dto: UserLoginDto): Promise<boolean> {
		return true;
	}
}

Так же добавим вывод id пользователя в запросе

users.controller.ts

async register(
	{ body }: Request<{}, {}, UserRegisterDto>,
	res: Response,
	next: NextFunction,
): Promise<void> {
	const result = await this.userService.createUser(body);
	if (!result) {
		return next(new HTTPError(422, 'Такой пользователь уже существует'));
	}
	this.ok(res, { email: result.email, id: result.id });
}

Этим запросом мы создали пользователя и записали в БД

При повторной отправке запроса у нас выпадает ошибка

И так же у нас присутствует инкрементированность id для новых пользователей

087 Упражнение - Логин пользователя

Первым делом нужно накинуть декораторы внутри класса-интерфейса, которые будут проверять получаемые значения

users > dto > user-login.dto.ts

import { IsEmail, IsString } from 'class-validator';
 
export class UserLoginDto {
	@IsEmail({}, { message: 'Неверно задан email' })
	email: string;
 
	@IsString()
	password: string;
}

В отдельную сущность пользователя сначала добавляем возможность передать пароль. Далее добавляем метод для сравнения паролей compare() из библиотеки bcryptjs

user.entity.ts

import { compare, hash } from 'bcryptjs';
 
export class User {
	private _password: string;
 
	constructor(
		private readonly _email: string,
		private readonly _name: string,
		// так же сюда передаём хеш пароля
		passwordHash?: string,
	) {
		// если хеш пароля передали, то
		if (passwordHash) {
			// присвоим пароль пользователя
			this._password = passwordHash;
		}
	}
 
	/// CODE ...
 
	// метод для проверки пароля
	public async comparePassword(pass: string): Promise<boolean> {
		// берём функцию для проверки переданного пароля с паролем пользователя
		return compare(pass, this._password);
	}
}

В сервисах пользователя реализуем валидацию данных, где мы сначала находим пользователя в базе по почте, а затем уже только проверяем пароль на соответствие

users.service.ts

async validateUser({ email, password }: UserLoginDto): Promise<boolean> {
	// проверяем при логине пользователя
	const existedUser = await this.usersRepository.find(email);
 
	// если пользователь не существует
	if (!existedUser) {
		return false;
	}
 
	// создаём инстанс пользователя, если он есть в базе
	const newUser = new User(existedUser.email, existedUser.name, existedUser.password);
 
	return newUser.comparePassword(password);
}

Уже в контроллере пользователя добавляем middlewares для обработки данных логина. Далее реализуем асинхронный метод логина пользователя, который будет возвращать ответ о статусе логина.

users.controller.ts

export class UserController extends BaseController implements IUserController {
 
	constructor(  
	   @inject(TYPES.ILogger) private loggerService: ILogger,  
	   @inject(TYPES.UserService) private userService: UserService,  
	) {  
	   super(loggerService);  
	   this.bindRoutes([  
	      {  
	         path: '/register',  
	         method: 'post',  
	         func: this.register,  
	         middlewares: [new ValidateMiddleware(UserRegisterDto)],  
	      },  
	      {  
	         path: '/login',  
	         method: 'post',  
	         func: this.login,  
	         // так же в миддлвейры добавляем сюда проверку логина  
	         middlewares: [new ValidateMiddleware(UserLoginDto)],  
	      },  
	   ]);  
	}
 
	// реализаця метода логина пользователя
	async login(
		req: Request<{}, {}, UserLoginDto>,
		res: Response,
		next: NextFunction,
	): Promise<void> {
		// получаем ответ от валидатора на основе переданного в него пользователя из тела запроса
		const result = await this.userService.validateUser(req.body);
 
		// если результата нет, то выведем ошибку
		if (!result) {
			return next(new HTTPError(401, 'ошибка авторизации', 'login'));
		}
 
		// если всё нормально, то отправляем ответ на фронт
		this.ok(res, {});
	}
 
	/// CODE ...
}

Если передать несуществующего пользователя, то получим ошибку

Если мы передадим существующего пользователя, то мы получим удовлетворительный ответ от сервера