DEV Community

John Piedrahita
John Piedrahita

Posted on • Updated on

First part, Authentication based on clean architecture

First part...

In this new installment I share with you several use cases for authenticating to an API, with the @clean/scaffold package.

Use cases:

  • A user may be able to register.
  • A user can log in to the system through jwt authentication.

We install the package globally on our pc.

npm i -g @tsclean/scaffold
Enter fullscreen mode Exit fullscreen mode

We create the project.

scaffold create:project --name=authentication
Enter fullscreen mode Exit fullscreen mode

We create the entity with the corresponding attributes, in this case we are going to store a user.

scaffold create:entity --name=user
Enter fullscreen mode Exit fullscreen mode

src/domain/models/user.ts

export type UserModel = {
  id: string | number;
  name: string;
  email: string;
  password: string;
  roles: UserRoleModel[];
}

export type UserRoleModel = [
    {
        role: string
    }
]

export type AddUserParams = Omit<UserModel, 'id'>
Enter fullscreen mode Exit fullscreen mode

Now we create the interface that will communicate the domain layer with the infrastructure layer. This interface will contain the use case.

Note: The interfaces when compiling the code to javascript are lost, for this reason to be able to apply the principle of Inversion of Dependencies, we must make reference in the communication of the components by means of a constant.

scaffold create:interface --name=add-user --path=models
Enter fullscreen mode Exit fullscreen mode

src/domain/models/gateways/add-user-repository.ts

import {UserModel, AddUserParams} from "@/domain/models/user";

export const ADD_USER_REPOSITORY = "ADD_USER_REPOSITORY";

export interface IAddUserRepository {
    addUser: (data: AddUserParams) => Promise<UserModel>;
}
Enter fullscreen mode Exit fullscreen mode

Now we create the service that is going to have all the logic to store the user.

scaffold create:service --name=add-user
Enter fullscreen mode Exit fullscreen mode

Interface to communicate the service with external layers.

src/domain/use-cases/add-user-service.ts

import {AddUserParams} from "@/domain/models/user";

export const ADD_USER_SERVICE = "ADD_USER_SERVICE";

export interface IAddUserService {
    addUser: (data: AddUserParams) => Promise<IAddUserService.Result | IAddUserService.Exist>
}

export namespace IAddUserService {
    export type Exist = boolean;
    export type Result = {
        id?: string | number
    }
}
Enter fullscreen mode Exit fullscreen mode

We create the business logic in the service, this involves applying some business rules.

src/domain/use-cases/impl/add-user-service-impl.ts

import {Adapter, Service} from "@tsclean/core";
import {IAddUserService} from "@/domain/use-cases/add-user-service";
import {AddUserParams} from "@/domain/models/user";
import {ADD_USER_REPOSITORY, IAddUserRepository} from "@/domain/models/gateways/add-user-repository";

@Service()
export class AddUserServiceImpl implements IAddUserService {
    constructor(
        @Adapter(ADD_USER_REPOSITORY) private readonly addUserRepository: IAddUserRepository
    ) {
    }

    async addUserService(data: AddUserParams): Promise<IAddUserService.Result | IAddUserService.Exist> {
        return await this.addUserRepository.addUserRepository(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the basic logic for the service to store the user, but we must check that the email is unique and create a hash for the password, so we get closer to a real world application.

We must create two interfaces for this purpose, one for the email validation and the other to create the password hash.

src/domain/models/gateways/check-email-repository.ts

export const CHECK_EMAIL_REPOSITORY = "CHECK_EMAIL_REPOSITORY";

export interface ICheckEmailRepository {
    checkEmail: (email: string) => Promise<ICheckEmailRepository.Result>
}

export namespace ICheckEmailRepository {
    export type Result = {
        id: string | number;
        firstName: string;
        password: string;
    }

}
Enter fullscreen mode Exit fullscreen mode

src/domain/models/gateways/hash-repository.ts

export const HASH_REPOSITORY = "HASH_REPOSITORY";

export interface IHashRepository {
    hash: (text: string) => Promise<string>
}
Enter fullscreen mode Exit fullscreen mode

Now that the interfaces have been created to handle some of the business rules, we implement the interfaces in the service, passing them as a dependency in the constructor.

src/domain/use-cases/impl/add-user-service-impl.ts

import {Adapter, Service} from "@tsclean/core";
import {IAddUserService} from "@/domain/use-cases/add-user-service";
import {AddUserParams} from "@/domain/models/user";
import {ADD_USER_REPOSITORY, IAddUserRepository} from "@/domain/models/gateways/add-user-repository";
import {CHECK_EMAIL_REPOSITORY, ICheckEmailRepository} from "@/domain/models/gateways/check-email-repository";
import {HASH_REPOSITORY, IHashRepository} from "@/domain/models/gateways/hash-repository";

@Service()
export class AddUserServiceImpl implements IAddUserService {
    constructor(
        @Adapter(HASH_REPOSITORY) private readonly hash: IHashRepository,
        @Adapter(CHECK_EMAIL_REPOSITORY) private readonly checkEmailRepository: ICheckEmailRepository,
        @Adapter(ADD_USER_REPOSITORY) private readonly addUserRepository: IAddUserRepository
    ) {
    }

    async addUserService(data: AddUserParams): Promise<IAddUserService.Result | IAddUserService.Exist> {
        const userExist = await this.checkEmailRepository.checkEmail(data.email);
        if (userExist) return true;

        const hashPassword = await this.hash.hash(data.password);
        const user = await this.addUserRepository.addUserRepository({...data, password: hashPassword});
        if (user) return user;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we create the adapter in infrastructure layer.

scaffold create:adapter-orm --name=user --orm=mongoose
Enter fullscreen mode Exit fullscreen mode

You must configure in the .env the url that you will use in the connection with mongoose.

Note: An update has been made in the plugin to give a management to the providers generating a single file, in this we include all the providers that are being created and by means of the spread operator we include them in the main container of the application so that the dependencies are solved.

src/infrastructure/driven-adapters/adapters/orm/mongoose/models/user.ts

import { model, Schema } from "mongoose";
import { UserModel } from '@/domain/models/user';

const schema = new Schema<UserModel>({
    id: String,
    firstName: String,
    lastName: String,
    email: String,
    password: String,
});

export const UserModelSchema = model<UserModel>('users', schema);
Enter fullscreen mode Exit fullscreen mode

src/infrastructure/driven-adapters/adapters/orm/mongoose/user-mongoose-repository-adapter.ts

import {AddUserParams, UserModel} from "@/domain/models/user";
import {UserModelSchema} from "@/infrastructure/driven-adapters/adapters/orm/mongoose/models/user";
import {IAddUserRepository} from "@/domain/models/gateways/add-user-repository";
import {ICheckEmailRepository} from "@/domain/models/gateways/check-email-repository";

export class UserMongooseRepositoryAdapter implements IAddUserRepository,
ICheckEmailRepository {

// We create this function to manage the entity that exists in the domain.
    map(data: any): any {
        const {_id, firstName, lastName, email, password} = data
        return Object.assign({}, {id: _id.toString(), firstName, lastName, email, password})
    }

    async addUserRepository(data: AddUserParams): Promise<UserModel> {
        return await UserModelSchema.create(data);
    }

    async checkEmail(email: string): Promise<ICheckEmailRepository.Result> {
        const user = await UserModelSchema.findOne({email}).exec();
        return user && this.map(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we create the adapter of an external library to create the hash of the password, for this we use bcrypt, where we make the implementation of the interface, decoupling completely the components.

src/infrastructure/driven-adapters/adapters/bcrypt-adapter.ts

import bcrypt from "bcrypt";
import {IHashRepository} from "@/domain/models/gateways/hash-repository";

export class BcryptAdapter implements IHashRepository {
    private readonly salt: number = 12;

    constructor() {
    }

    async hash(text: string): Promise<string> {
        return await bcrypt.hash(text, this.salt);
    }
}
Enter fullscreen mode Exit fullscreen mode

src/infrastructure/driven-adapters/providers/index.ts

import {BcryptAdapter} from "@/infrastructure/driven-adapters/adapters/bcrypt-adapter";
import {UserMongooseRepositoryAdapter} from "@/infrastructure/driven-adapters/adapters/orm/mongoose/user-mongoose-repository-adapter";
import {AddUserServiceImpl} from "@/domain/use-cases/impl/add-user-service-impl";
import {ADD_USER_REPOSITORY} from "@/domain/models/gateways/add-user-repository";
import {CHECK_EMAIL_REPOSITORY} from "@/domain/models/gateways/check-email-repository";
import {ADD_USER_SERVICE} from "@/domain/use-cases/add-user-service";
import {HASH_REPOSITORY} from "@/domain/models/gateways/hash-repository";


export const adapters = [
    {
        classAdapter: BcryptAdapter,
        key: HASH_REPOSITORY
    },
    {
        classAdapter: UserMongooseRepositoryAdapter,
        key: ADD_USER_REPOSITORY
    },
    {
        classAdapter: UserMongooseRepositoryAdapter,
        key: CHECK_EMAIL_REPOSITORY
    }
]

export const services = [
    {
        classAdapter: AddUserServiceImpl,
        key: ADD_USER_SERVICE
    }
]
Enter fullscreen mode Exit fullscreen mode

We create the controller as an entry point.

scaffold create:controller --name=add-user
Enter fullscreen mode Exit fullscreen mode

src/infrastructure/entry-points/api/add-user-controller.ts

import {Mapping, Post, Body, Adapter} from "@tsclean/core";
import {AddUserParams} from "@/domain/models/user";
import {ADD_USER_SERVICE, IAddUserService} from "@/domain/use-cases/add-user-service";

@Mapping('api/v1/add-user')
export class AddUserController {

    constructor(
        @Adapter(ADD_USER_SERVICE) private readonly addUserService: IAddUserService
    ) {
    }

    @Post()
    async addUserController(@Body() data: AddUserParams): Promise<IAddUserService.Result | IAddUserService.Exist> {
        return this.addUserService.addUserService(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

We are already validating in the use case that the email is unique, but it returns only a boolean value, we must handle this exception at the entry point, in this case the controller, in addition we validate that the email has the correct format and the body of the request does not bring empty fields.

To achieve this we create our own helper or we make use of an external library, if you make use of a library you must create the corresponding adapter for this purpose.

src/infrastructure/helpers/validate-fields.ts

export const REGEX = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/

export class ValidateFields {

    static fieldsValidation(data: any) {
        let errors = {}
        for (const key in data) {
            if (ValidateFields.isFieldEmpty(data[key])) {
                errors[key] = `${key} field is required`
            } else if (key === "email" && !REGEX.test(data[key])) {
                errors[key] = `${key} is invalid`
            }
        }

        return { errors, isValid: ValidateFields.isFieldEmpty(errors) }
    }

    private static isFieldEmpty (value: any): boolean {
        if (value === undefined || value === null ||
            typeof value === "object" && Object.keys(value).length === 0 ||
            typeof value === "string" && value.trim().length === 0) {
            return true
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

src/infrastructure/entry-points/api/add-user-controller.ts

import {Mapping, Post, Body, Adapter} from "@tsclean/core";
import {AddUserParams} from "@/domain/models/user";
import {ADD_USER_SERVICE, IAddUserService} from "@/domain/use-cases/add-user-service";
import {ValidateFields} from "@/infrastructure/helpers/validate-fields";

@Mapping('api/v1/add-user')
export class AddUserController {

    constructor(
        @Adapter(ADD_USER_SERVICE) private readonly addUserService: IAddUserService
    ) {
    }

    @Post()
    async addUserController(@Body() data: AddUserParams): Promise<IAddUserService.Result | IAddUserService.Exist | any> {

        const {errors, isValid} = ValidateFields.fieldsValidation(data);

        if (!isValid) return {statusCode: 422, body: {"message": errors}}

        const account = await this.addUserService.addUserService(data);

        if (account === true) return {statusCode: 400, body: {"message": "Email is already in use"}}

        return account;
    }
}
Enter fullscreen mode Exit fullscreen mode

We create an index file to export all the controllers to the container.

src/infrastructure/entry-points/api/index.ts

import {AddUserController} from "@/infrastructure/entry-points/api/add-user-controller";

export const controllers = [
    AddUserController
]
Enter fullscreen mode Exit fullscreen mode

Finally we configure all the components in the main container

src/application/app.ts

import {Container} from "@tsclean/core";
import {controllers} from "@/infrastructure/entry-points/api";
import {adapters, services} from "@/infrastructure/driven-adapters/providers";

@Container({
    imports: [],
    providers: [...services, ...adapters],
    controllers: [...controllers],
})

export class AppContainer {
}
Enter fullscreen mode Exit fullscreen mode

The code looks much cleaner with the update that was made.

Previously when creating the adapter, the index.ts file that starts the application was updated with the necessary configuration to make the connection with the database manager.

src/index.ts

import 'module-alias/register'

import helmet from 'helmet';
import {connect} from 'mongoose';
import {StartProjectInit} from "@tsclean/core";

import {AppContainer} from "@/application/app";
import {MONGODB_URI, PORT} from "@/application/config/environment";

async function run(): Promise<void> {
    await connect(MONGODB_URI);
    console.log('DB Mongo connected')
    const app = await StartProjectInit.create(AppContainer);
    app.use(helmet());
    await app.listen(PORT, () => console.log('Running on port: ' + PORT))
}

run();
Enter fullscreen mode Exit fullscreen mode

next second part...

https://dev.to/japhernandez/authentication-based-on-clean-architecture-38el

Top comments (0)