├── .env.template ├── .env.test ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── jest.config.ts ├── package.json ├── src ├── app.test.ts ├── app.ts ├── core │ ├── config │ │ ├── encrypt.adapter.ts │ │ ├── envs.adapter.ts │ │ ├── envs.config.adapter.ts │ │ ├── index.ts │ │ ├── jwt.adapter.ts │ │ └── setupTests.ts │ ├── constants │ │ └── index.ts │ ├── errors │ │ ├── custom.error.ts │ │ └── index.ts │ ├── index.ts │ └── types │ │ └── index.ts ├── features │ ├── auth │ │ ├── domain │ │ │ ├── datasources │ │ │ │ └── datasource.ts │ │ │ ├── dtos │ │ │ │ ├── index.ts │ │ │ │ ├── login.dto.ts │ │ │ │ └── register.dto.ts │ │ │ ├── entities │ │ │ │ ├── auth.entity.ts │ │ │ │ ├── index.ts │ │ │ │ └── user.entity.ts │ │ │ ├── index.ts │ │ │ ├── repositories │ │ │ │ └── repository.ts │ │ │ └── usecases │ │ │ │ ├── getUserById.usecase.ts │ │ │ │ ├── index.ts │ │ │ │ ├── login.usecase.ts │ │ │ │ └── register.usecase.ts │ │ ├── index.ts │ │ ├── infraestructure │ │ │ ├── index.ts │ │ │ ├── local.datasource.impl.ts │ │ │ └── repository.impl.ts │ │ └── presentation │ │ │ ├── controller.ts │ │ │ ├── middlewares │ │ │ ├── auth.middleware.ts │ │ │ └── index.ts │ │ │ └── routes.ts │ ├── shared │ │ ├── domain │ │ │ ├── dtos │ │ │ │ ├── core.dto.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pagination.dto.test.ts │ │ │ │ └── pagination.dto.ts │ │ │ ├── entities │ │ │ │ ├── index.ts │ │ │ │ ├── paginationResponse.entity.test.ts │ │ │ │ └── paginationResponse.entity.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── presentation │ │ │ ├── index.ts │ │ │ └── middlewares │ │ │ ├── custom.middleware.ts │ │ │ ├── error.middleware.ts │ │ │ └── index.ts │ └── todos │ │ ├── domain │ │ ├── datasources │ │ │ ├── datasource.test.ts │ │ │ └── datasource.ts │ │ ├── dtos │ │ │ ├── create.dto.test.ts │ │ │ ├── create.dto.ts │ │ │ ├── getById.dto.test.ts │ │ │ ├── getById.dto.ts │ │ │ ├── index.ts │ │ │ ├── update.dto.test.ts │ │ │ └── update.dto.ts │ │ ├── entities │ │ │ ├── index.ts │ │ │ ├── todo.entity.test.ts │ │ │ └── todo.entity.ts │ │ ├── index.ts │ │ ├── repositories │ │ │ └── respository.ts │ │ └── usecases │ │ │ ├── create.usecase.test.ts │ │ │ ├── create.usecase.ts │ │ │ ├── delete.usecase.test.ts │ │ │ ├── delete.usecase.ts │ │ │ ├── getAll.usecase.test.ts │ │ │ ├── getAll.usecase.ts │ │ │ ├── getById.usecase.test.ts │ │ │ ├── getById.usecase.ts │ │ │ ├── index.ts │ │ │ ├── update.usecase.test.ts │ │ │ └── update.usecase.ts │ │ ├── index.ts │ │ ├── infraestructure │ │ ├── index.ts │ │ ├── local.datasource.impl.test.ts │ │ ├── local.datasource.impl.ts │ │ ├── repository.impl.test.ts │ │ └── repository.impl.ts │ │ └── presentation │ │ ├── controller.ts │ │ ├── routes.test.ts │ │ └── routes.ts ├── routes.ts ├── server.ts └── testServer.ts ├── tsconfig.json └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DEFAULT_API_PREFIX=/api/v1 3 | NODE_ENV=development 4 | JWT_SEED=noDET3mPlateS3rv3r -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DEFAULT_API_PREFIX=/api/v1/test 3 | NODE_ENV=development 4 | JWT_SEED=noDET3mPlateS3rv3r -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | jest.config.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "standard-with-typescript", 8 | "plugin:prettier/recommended", 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "plugins": ["@typescript-eslint", "import", "prettier"], 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module", 16 | "project": "./tsconfig.json" 17 | }, 18 | "ignorePatterns": ["src/**/*.test.ts"], 19 | "rules": { 20 | "prettier/prettier": "error", 21 | "camelcase": "error", 22 | "spaced-comment": "error", 23 | "quotes": ["error", "single"], 24 | "no-duplicate-imports": "error", 25 | "no-unused-vars": "off", 26 | "no-magic-numbers": "off", 27 | "@typescript-eslint/no-unused-vars": "error", 28 | "@typescript-eslint/explicit-function-return-type": "error", 29 | "@typescript-eslint/strict-boolean-expressions": "off", 30 | "@typescript-eslint/no-extraneous-class": "off", 31 | "@typescript-eslint/no-magic-numbers": "error", 32 | "@typescript-eslint/no-unsafe-argument": "off" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .env -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baguilar6174/node-template-server/3c440f6a8a8a47966077212cbf8d0728440ab021/.nvmrc -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | dist 3 | node_modules 4 | .prettierrc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "insertPragma": false, 5 | "printWidth": 120, 6 | "proseWrap": "preserve", 7 | "quoteProps": "as-needed", 8 | "requirePragma": false, 9 | "semi": true, 10 | "singleQuote": true, 11 | "tabWidth": 2, 12 | "trailingComma": "none", 13 | "useTabs": true, 14 | "endOfLine": "auto", 15 | "overrides": [ 16 | { 17 | "files": ".prettierrc", 18 | "options": { "parser": "typescript" } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Template REST API 2 | 3 | This reporsitory contains a template for projects with Node, Express, Typescript. The test environment has been configured using Jest, and ESLint and Prettier have been integrated to set code style definitions. You can find the step-by-step construction of this project in this article: 4 | 5 | [Boilerplate for your Node projects with Express](https://baguilar6174.medium.com/boilerplate-for-your-node-projects-with-express-add98ea89c9f) 6 | 7 | Explore the world of API development using Node.js, Express, and TypeScript. Learn how to implement Clean Architecture and best programming practices to create robust, scalable, and maintainable web services. Whether you're a seasoned developer or just starting out, this repository provides comprehensive resources, tutorials, and examples to help you master API development with confidence. 8 | 9 | [Modern API Development with Node.js, Express, and TypeScript using Clean Architecture](https://baguilar6174.medium.com/modern-api-development-with-node-js-express-and-typescript-using-clean-architecture-0868607b76de) 10 | 11 | ## Installation 12 | 13 | Clone this repository 14 | 15 | ```bash 16 | git clone https://github.com/baguilar6174/node-template-server.git 17 | ``` 18 | 19 | Install dependencies 20 | 21 | ```bash 22 | yarn 23 | ``` 24 | 25 | Clone `.env.template` file and rename to `.env`. To test the project, you can use the `.env.test` file. 26 | 27 | Replace your environment variables in `.env` file 28 | 29 | ## Running the app 30 | 31 | Run `yarn dev` 32 | 33 | If your want to create build production, run `yarn build` 34 | 35 | If your want to run tests, run `yarn test` or `yarn test:watch` 36 | 37 | If you want to know outdated dependencies, run `yarn outdated` 38 | 39 | ## My process 40 | 41 | ### Built with 42 | 43 | - Node 44 | - Typescript 45 | - Express 46 | - ESLint & Prettier 47 | - Environment Variables 48 | - Unit testing with Jest & Supertest 49 | - Clean Architecture 50 | - Repository Pattern 51 | - Adapter Pattern 52 | - Use Cases 53 | - DTOs (Data Transfer Objects) 54 | 55 | ## API Documentation 56 | 57 | This entire implementation is based on an in-memory database for example purposes. **NO** real database is being used, when the server is re-run, the in-memory database **will be re-created**. 58 | 59 | Also, simple implementations have been created to **encrypt** and **validate authentication tokens** (no third party dependencies are used for this). If you use this repository as an example, it is recommended that you modify these implementations using libraries or your own implementations (`src/core/config` folder). 60 | 61 | ### Authentication 62 | 63 | ### POST `/api/v1/auth/register` 64 | 65 | Registers a new user in memory database. 66 | 67 | #### Request Body 68 | 69 | ```json 70 | { 71 | "name": "string", 72 | "email": "string", 73 | "password": "string" 74 | } 75 | ``` 76 | 77 | #### Response Codes 78 | 79 | - `400 Bad Request`: Returned if the request body is invalid. 80 | - `201 Created`: Returned if the request is successful. 81 | 82 | #### Response 83 | 84 | ```json 85 | { 86 | "data": { 87 | "user": { 88 | "id": "string", 89 | "name": "string", 90 | "email": "string", 91 | "emailVerified": "boolean", 92 | "role": "string[]" 93 | }, 94 | "token": "string" 95 | } 96 | } 97 | ``` 98 | 99 | ### POST `/api/v1/auth/login` 100 | 101 | Logs in a user. 102 | 103 | #### Request Body 104 | 105 | ```json 106 | { 107 | "email": "string", 108 | "password": "string" 109 | } 110 | ``` 111 | 112 | #### Response Codes 113 | 114 | - `400 Bad Request`: Returned if the request body is invalid. 115 | - `200 OK`: Returned if the request is successful. 116 | 117 | #### Response 118 | 119 | ```json 120 | { 121 | "data": { 122 | "user": { 123 | "id": "string", 124 | "name": "string", 125 | "email": "string", 126 | "emailVerified": "boolean", 127 | "role": "string[]" 128 | }, 129 | "token": "string" 130 | } 131 | } 132 | ``` 133 | 134 | --- 135 | 136 | ### GET `/api/v1/todos` 137 | 138 | Retrieves a paginated list of todos. 139 | 140 | #### Query Parameters 141 | 142 | - `page` (number, optional): The page number to retrieve. Defaults to `1`. 143 | - `limit` (number, optional): The number of items per page. Defaults to `10`. 144 | 145 | #### Response Codes 146 | 147 | Ensure that the values for page and limit are valid positive integers to avoid errors. 148 | 149 | - `400 Bad Request`: Returned if query parameters are invalid. 150 | - `200 OK`: Returned if the request is successful. 151 | 152 | #### Response 153 | 154 | ```json 155 | { 156 | "data": { 157 | "results": [ 158 | { 159 | "id": "number", 160 | "text": "string", 161 | "isCompleted": "boolean" 162 | } 163 | ], 164 | "currentPage": "number", 165 | "nextPage": "number | null", 166 | "prevPage": "number | null", 167 | "total": "number", 168 | "totalPages": "number" 169 | } 170 | } 171 | ``` 172 | 173 | ### GET `/api/v1/todos/:id` 174 | 175 | Retrieves a single todo item by its id. 176 | 177 | #### Path Parameters 178 | 179 | - `id` (number): The id of the todo item to retrieve. 180 | 181 | #### Response Codes 182 | 183 | - `404 Not Found`: Returned if the todo item with the specified id does not exist. 184 | - `200 OK`: Returned if the request is successful. 185 | 186 | #### Response 187 | 188 | ```json 189 | { 190 | "data": { 191 | "id": "number", 192 | "text": "string", 193 | "isCompleted": "boolean" 194 | } 195 | } 196 | ``` 197 | 198 | ### POST `/api/v1/todos` 199 | 200 | Creates a new todo item. You need to be `logged in` previously. This endpoint requires authorization because it is protected with AuthMiddleware. 201 | 202 | #### Authorization 203 | 204 | Bearer `` 205 | 206 | #### Request Body 207 | 208 | ```json 209 | { 210 | "text": "string" 211 | } 212 | ``` 213 | 214 | #### Response Codes 215 | 216 | - `401 Unauthorized`: Returned if the request is not authorized. 217 | - `400 Bad Request`: Returned if the request body is invalid. 218 | - `201 Created`: Returned if the request is successful. 219 | 220 | #### Response 221 | 222 | ```json 223 | { 224 | "data": { 225 | "id": "number", 226 | "text": "string", 227 | "isCompleted": "boolean" 228 | } 229 | } 230 | ``` 231 | 232 | ### PUT `/api/v1/todos/:id` 233 | 234 | Updates a todo item properties by its id. 235 | 236 | #### Path Parameters 237 | 238 | - `id` (number): The id of the todo item to update. 239 | 240 | #### Request Body 241 | 242 | ```json 243 | { 244 | "text": "string", 245 | "isCompleted": "boolean" 246 | } 247 | ``` 248 | 249 | #### Response Codes 250 | 251 | - `404 Not Found`: Returned if the todo item with the specified id does not exist. 252 | - `400 Bad Request`: Returned if the request body is invalid. 253 | - `200 OK`: Returned if the request is successful. 254 | 255 | #### Response 256 | 257 | ```json 258 | { 259 | "data": { 260 | "id": "number", 261 | "text": "string", 262 | "isCompleted": "boolean" 263 | } 264 | } 265 | ``` 266 | 267 | ### DELETE `/api/v1/todos/:id` 268 | 269 | Deletes a todo item by its id. 270 | 271 | #### Path Parameters 272 | 273 | - `id` (number): The id of the todo item to delete. 274 | 275 | #### Response Codes 276 | 277 | - `404 Not Found`: Returned if the todo item with the specified id does not exist. 278 | - `200 OK`: Returned if the request is successful. 279 | 280 | #### Response 281 | 282 | ```json 283 | { 284 | "data": { 285 | "id": "number", 286 | "text": "string", 287 | "isCompleted": "boolean" 288 | } 289 | } 290 | ``` 291 | 292 | --- 293 | 294 | ## Project Structure 295 | 296 | ```bash 297 | node-template-server/ 298 | │ 299 | ├── dist/ 300 | ├── node_modules/ 301 | ├── src/ 302 | │ ├── core/ 303 | │ │ ├── config/ 304 | │ │ ├── constants/ 305 | │ │ ├── errors/ 306 | │ │ └── types/ 307 | │ ├── features/ 308 | │ │ ├── auth/ 309 | │ │ │ ├── domain/ 310 | │ │ │ │ ├── datasources/ 311 | │ │ │ │ ├── dtos/ 312 | │ │ │ │ ├── entities/ 313 | │ │ │ │ ├── repositories/ 314 | │ │ │ │ └── usecases/ 315 | │ │ │ │ 316 | │ │ │ ├── infrastructure/ 317 | │ │ │ │ ├── local.datasource.impl.ts 318 | │ │ │ │ └── repository.impl.ts 319 | │ │ │ │ 320 | │ │ │ └── presentation/ 321 | │ │ │ ├── controller.ts 322 | │ │ │ └── routes.ts 323 | │ │ │ 324 | │ │ ├── shared/ 325 | │ │ │ ├── domain/ 326 | │ │ │ │ ├── dtos/ 327 | │ │ │ │ ├── entities/ 328 | │ │ │ └── presentation/ 329 | │ │ │ └── middlewares/ 330 | │ │ │ 331 | │ │ ├── todos/ 332 | │ │ │ ├── domain/ 333 | │ │ │ │ ├── datasources/ 334 | │ │ │ │ ├── dtos/ 335 | │ │ │ │ ├── entities/ 336 | │ │ │ │ ├── repositories/ 337 | │ │ │ │ └── usecases/ 338 | │ │ │ │ 339 | │ │ │ ├── infrastructure/ 340 | │ │ │ │ ├── local.datasource.impl.ts 341 | │ │ │ │ └── repository.impl.ts 342 | │ │ │ │ 343 | │ │ │ └── presentation/ 344 | │ │ │ ├── controller.ts 345 | │ │ │ └── routes.ts 346 | │ │ └── ... 347 | │ ├── app.test.ts 348 | │ ├── app.ts 349 | │ ├── routes.ts 350 | │ ├── server.ts 351 | │ └── testServer.ts 352 | ├── .env 353 | ├── .env.template 354 | ├── .env.test 355 | ├── ... 356 | ├── package.json 357 | └── ... 358 | ``` 359 | 360 | ### Domain — Entities 361 | 362 | Entities are objects that represent fundamental concepts of the application domain. These objects encapsulate the essential state and behavior of key elements within the system. 363 | 364 | ### Domain — Repositories 365 | 366 | Repositories are a data access abstraction that act as an interface between the domain layer and the infrastructure layer. Their primary purpose is to encapsulate the logic related to data storage and retrieval, providing an abstraction layer that allows the domain layer to work with entities without worrying about the specific details of how data is stored or retrieved. 367 | 368 | ### Domain — Use cases 369 | 370 | Use cases represent the specific actions or functionalities that can be performed by a user or a system within the application. These use cases encapsulate the business logic in a way that is independent of infrastructure and implementation details, making them portable and reusable in different contexts. 371 | 372 | ### Domain — Data sources 373 | 374 | Data sources are interfaces or abstractions that represent the data source from which the data needed for the application is obtained. These data sources can be databases, web services, file systems, or any other form of data storage. The use of data sources helps decouple business logic from the specific details of the data source. This means that the domain layer can work with data sources through generic interfaces without knowing the specific implementation details, making it easy to exchange or update the data source without affecting the application logic. 375 | 376 | ### Domain — DTOs 377 | 378 | DTOs (Data Transfer Objects) are objects that are used to transfer data between different layers of the application, especially between the presentation layer and the domain or infrastructure layer. DTOs encapsulate related data and transport it from one context to another without exposing the underlying business logic. The main function of DTOs is to represent information in a structured and coherent way, facilitating its transport through the application. 379 | 380 | ### Infrastructure — Repository Implementation 381 | 382 | The repository implementation at the infrastructure layer is responsible for providing a concrete implementation of the methods defined in the repository interface at the domain layer. This implementation is responsible for interacting with the actual data source, such as a database, an external service or any other data persistence mechanism. 383 | 384 | ### Infrastructure — Data source Implementation 385 | 386 | The data source implementation in the infrastructure layer is responsible for providing a concrete implementation of the methods defined in the data source interface in the domain layer. This component is responsible for interacting directly with the actual data source, such as a database, a web service or any other data storage medium. 387 | 388 | ### Presentation— Controller 389 | 390 | Controllers are presentation layer components that act as entry points for client requests in an application. These controllers are responsible for receiving HTTP requests, processing them and directing them to the corresponding business logic in the domain layer. 391 | 392 | ### Presentation — Routes 393 | 394 | Routes are presentation layer components that are responsible for defining routes and handling incoming HTTP requests to an application. These routes are used to map HTTP requests to the corresponding controllers and establish the API structure or routing of the application. It is also where our data source and our repository are initialized, the same that is necessary for our controller. 395 | 396 | --- 397 | 398 | Implementing a REST API using Node.js, Express and following good development practices and Clean Architecture provides a solid foundation for developing modern and scalable web applications. By taking a modular approach and focusing on separation of concerns, developers can achieve a clean, maintainable architecture that encourages flexibility and continuous system evolution. 399 | 400 | The application of Clean Architecture allows you to maintain a clear separation between the different layers of the application, such as the domain layer, the infrastructure layer, and the presentation layer, making it easier to understand and maintain the code over time. Additionally, adopting good development practices such as using middlewares for intermediate tasks, validating input data, and proper error handling contributes to creating a robust and secure API. 401 | 402 | --- 403 | 404 | ## Stay in touch 405 | 406 | - Website - [www.bryan-aguilar.com](https://www.bryan-aguilar.com/) 407 | - Medium - [baguilar6174](https://baguilar6174.medium.com/) 408 | - LinkedIn - [baguilar6174](https://www.linkedin.com/in/baguilar6174) 409 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | collectCoverage: true, 5 | coverageDirectory: 'coverage', 6 | coverageProvider: 'v8', 7 | preset: 'ts-jest', 8 | testEnvironment: 'jest-environment-node', 9 | setupFiles: ['/src/core/config/setupTests.ts'] 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-template-server", 3 | "version": "1.0.0", 4 | "description": "Boilerplate Node projects with Express, Typescript and clean architecture", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=20.12.0", 8 | "yarn": ">=1.22.19", 9 | "npm": "please-use-yarn" 10 | }, 11 | "scripts": { 12 | "test": "jest", 13 | "test:watch": "jest --watchAll", 14 | "test:coverage": "jest --coverage", 15 | "dev": "ts-node-dev --respawn --clear --transpile-only --ignore-watch node_modules ./src/app.ts", 16 | "build": "yarn test && rimraf ./dist && tsc", 17 | "start": "node dist/app.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/baguilar6174/node-template-server.git" 22 | }, 23 | "keywords": [ 24 | "API", 25 | "REST", 26 | "Express", 27 | "Node", 28 | "Clean Architecture" 29 | ], 30 | "author": "Bryan Aguilar", 31 | "license": "ISC", 32 | "bugs": { 33 | "url": "https://github.com/baguilar6174/node-template-server/issues" 34 | }, 35 | "homepage": "https://github.com/baguilar6174/node-template-server#readme", 36 | "devDependencies": { 37 | "@types/compression": "^1.7.5", 38 | "@types/express": "^5.0.0", 39 | "@types/jest": "^29.5.14", 40 | "@types/node": "^22.13.10", 41 | "@types/supertest": "^6.0.2", 42 | "@typescript-eslint/eslint-plugin": "^8.26.0", 43 | "eslint": "^9.22.0", 44 | "eslint-config-prettier": "^10.1.1", 45 | "eslint-config-standard-with-typescript": "^43.0.1", 46 | "eslint-plugin-import": "^2.31.0", 47 | "eslint-plugin-n": "^17.16.2", 48 | "eslint-plugin-prettier": "^5.2.3", 49 | "eslint-plugin-promise": "^7.2.1", 50 | "jest": "^29.7.0", 51 | "prettier": "^3.5.3", 52 | "rimraf": "^6.0.1", 53 | "supertest": "^7.0.0", 54 | "ts-jest": "^29.2.6", 55 | "ts-node-dev": "^2.0.0", 56 | "typescript": "^5.8.2" 57 | }, 58 | "dependencies": { 59 | "compression": "^1.8.0", 60 | "dotenv": "^16.4.7", 61 | "env-var": "^7.5.0", 62 | "express": "^4.21.2", 63 | "express-rate-limit": "^7.5.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app.test.ts: -------------------------------------------------------------------------------- 1 | // src\app.test.ts 2 | 3 | import { Server } from './server'; 4 | import { envs } from './core'; 5 | 6 | jest.mock('./server'); 7 | 8 | describe('tests in app.ts', () => { 9 | test('should call server with correct arguments and start it', async () => { 10 | await import('./app'); 11 | expect(Server).toHaveBeenCalledTimes(1); 12 | expect(Server).toHaveBeenCalledWith({ 13 | port: envs.PORT, 14 | apiPrefix: envs.API_PREFIX, 15 | routes: expect.any(Function) 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | // src\app.ts 2 | 3 | import { envs } from './core'; 4 | import { AppRoutes } from './routes'; 5 | import { Server } from './server'; 6 | 7 | (() => { 8 | main(); 9 | })(); 10 | 11 | function main(): void { 12 | // * At this point you can connect to your database for example MongoDB 13 | 14 | const server = new Server({ 15 | port: envs.PORT, 16 | apiPrefix: envs.API_PREFIX, 17 | routes: AppRoutes.routes 18 | }); 19 | void server.start(); 20 | } 21 | -------------------------------------------------------------------------------- /src/core/config/encrypt.adapter.ts: -------------------------------------------------------------------------------- 1 | import { createHash, randomBytes } from 'crypto'; 2 | 3 | import { TEN } from '../constants'; 4 | 5 | // Generate a random salt 6 | const salt = randomBytes(TEN).toString('hex'); 7 | 8 | /** 9 | * Basic encryption adapter for password hashing and comparison. 10 | * Here you can use any encryption library you want. 11 | */ 12 | export const basicEncript = { 13 | /** 14 | * Generates a hash for a password with a salt. 15 | * @param password - The password to hash. 16 | * @returns - The hashed password. 17 | */ 18 | hashPassword: (password: string): string => { 19 | // Create the hash using the salt and the password 20 | const hash = createHash('sha256') 21 | .update(salt + password) 22 | .digest('hex'); 23 | 24 | return hash; 25 | }, 26 | 27 | /** 28 | * Compares a password with a given hash and salt. 29 | * @param password - The password to verify. 30 | * @param hash - The original hash to compare with. 31 | * @returns - True if the password matches, false otherwise. 32 | */ 33 | comparePassword: (password: string, hash: string): boolean => { 34 | // Create a new hash with the given salt and password 35 | const newHash = createHash('sha256') 36 | .update(salt + password) 37 | .digest('hex'); 38 | 39 | // Compare the new hash with the original hash 40 | return newHash === hash; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/config/envs.adapter.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import { get } from 'env-var'; 3 | 4 | export const envs = { 5 | PORT: get('PORT').required().asPortNumber(), 6 | API_PREFIX: get('DEFAULT_API_PREFIX').default('/api/v1').asString(), 7 | NODE_ENV: get('NODE_ENV').default('development').asString(), 8 | JWT_SEED: get('JWT_SEED').required().asString() 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/config/envs.config.adapter.ts: -------------------------------------------------------------------------------- 1 | import { envs } from './envs.adapter'; 2 | 3 | describe('tests in envs.config.test.ts', () => { 4 | test('should return env options', () => { 5 | expect(envs).toEqual({ 6 | PORT: 3000, 7 | API_PREFIX: '/api/v1/test', 8 | NODE_ENV: 'test' 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/core/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './envs.adapter'; 2 | export * from './encrypt.adapter'; 3 | export * from './jwt.adapter'; 4 | -------------------------------------------------------------------------------- /src/core/config/jwt.adapter.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | import { FOUR, ONE_THOUSAND, SIXTY, THREE } from '../constants'; 4 | import { envs } from './envs.adapter'; 5 | 6 | const JWT_SEED = envs.JWT_SEED; 7 | 8 | /** 9 | * JWT adapter for basic authentication. 10 | */ 11 | export const basicJWT = { 12 | /** 13 | * Creates a JWT token. 14 | * @param {Record} payload - The payload of the token. 15 | * @param {number} expiresIn - The token expiration time in seconds. 16 | * @returns {string} The generated JWT token. 17 | */ 18 | generateToken: (payload: Record, expiresIn: number = SIXTY * SIXTY): string => { 19 | const header = { alg: 'HS256', typ: 'JWT' }; 20 | 21 | const exp = Math.floor(Date.now() / ONE_THOUSAND) + expiresIn; 22 | const payloadWithExp = { ...payload, exp }; 23 | 24 | const headerEncoded = base64UrlEncode(JSON.stringify(header)); 25 | const payloadEncoded = base64UrlEncode(JSON.stringify(payloadWithExp)); 26 | 27 | const signature = crypto 28 | .createHmac('sha256', JWT_SEED) 29 | .update(`${headerEncoded}.${payloadEncoded}`) 30 | .digest('base64') 31 | .replace(/=/g, '') 32 | .replace(/\+/g, '-') 33 | .replace(/\//g, '_'); 34 | 35 | return `${headerEncoded}.${payloadEncoded}.${signature}`; 36 | }, 37 | 38 | /** 39 | * Verifies a JWT token. 40 | * @param {string} token - The JWT token to verify. 41 | * @returns {Record | null} The decoded payload if the token is valid, otherwise null. 42 | */ 43 | validateToken: (token: string): T | null => { 44 | const [headerEncoded, payloadEncoded, signature] = token.split('.'); 45 | 46 | const signatureCheck = crypto 47 | .createHmac('sha256', JWT_SEED) 48 | .update(`${headerEncoded}.${payloadEncoded}`) 49 | .digest('base64') 50 | .replace(/=/g, '') 51 | .replace(/\+/g, '-') 52 | .replace(/\//g, '_'); 53 | 54 | if (signature !== signatureCheck) { 55 | return null; 56 | } 57 | 58 | const payload = JSON.parse(base64UrlDecode(payloadEncoded)); 59 | return payload; 60 | } 61 | }; 62 | 63 | /** 64 | * Encodes a string or Buffer to Base64 URL-safe format. 65 | * @param {string | Buffer} data - The data to encode. 66 | * @returns {string} The Base64 URL-safe encoded string. 67 | */ 68 | function base64UrlEncode(data: string | Buffer): string { 69 | if (typeof data === 'string') { 70 | return Buffer.from(data, 'utf8').toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 71 | } else if (Buffer.isBuffer(data)) { 72 | return data.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); 73 | } else { 74 | throw new Error('Unsupported data type for base64UrlEncode'); 75 | } 76 | } 77 | 78 | /** 79 | * Decodes a Base64 URL-safe string. 80 | * @param {string} base64Url - The Base64 URL-safe encoded string. 81 | * @returns {string} The decoded string. 82 | */ 83 | function base64UrlDecode(base64Url: string): string { 84 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((THREE * base64Url.length) % FOUR); 85 | return Buffer.from(base64, 'base64').toString('utf8'); 86 | } 87 | -------------------------------------------------------------------------------- /src/core/config/setupTests.ts: -------------------------------------------------------------------------------- 1 | // Process before run application 2 | 3 | import { config } from 'dotenv'; 4 | 5 | config({ 6 | path: '.env.test' 7 | }); 8 | -------------------------------------------------------------------------------- /src/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-magic-numbers */ 2 | 3 | export const ZERO = 0 as const; 4 | export const ONE = 1 as const; 5 | export const THREE = 3 as const; 6 | export const FOUR = 4 as const; 7 | export const SIX = 6 as const; 8 | export const TEN = 10 as const; 9 | export const SIXTY = 60 as const; 10 | export const ONE_HUNDRED = 100 as const; 11 | export const ONE_THOUSAND = 1000 as const; 12 | 13 | export const REGEX_EMAIL = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; 14 | 15 | export enum HttpCode { 16 | OK = 200, 17 | CREATED = 201, 18 | NO_CONTENT = 204, 19 | BAD_REQUEST = 400, 20 | UNAUTHORIZED = 401, 21 | FORBIDDEN = 403, 22 | NOT_FOUND = 404, 23 | INTERNAL_SERVER_ERROR = 500 24 | } 25 | -------------------------------------------------------------------------------- /src/core/errors/custom.error.ts: -------------------------------------------------------------------------------- 1 | // src\core\errors\custom.error.ts 2 | 3 | import { HttpCode } from '../constants'; 4 | import { type ValidationType } from '../types'; 5 | 6 | interface AppErrorArgs { 7 | name?: string; 8 | statusCode: HttpCode; 9 | message: string; 10 | isOperational?: boolean; 11 | validationErrors?: ValidationType[]; 12 | } 13 | 14 | export class AppError extends Error { 15 | public readonly name: string; 16 | public readonly statusCode: HttpCode; 17 | public readonly isOperational: boolean = true; 18 | public readonly validationErrors?: ValidationType[]; 19 | 20 | constructor(args: AppErrorArgs) { 21 | const { message, name, statusCode, isOperational, validationErrors } = args; 22 | super(message); 23 | Object.setPrototypeOf(this, new.target.prototype); 24 | this.name = name ?? 'Aplication Error'; 25 | this.statusCode = statusCode; 26 | if (isOperational !== undefined) this.isOperational = isOperational; 27 | this.validationErrors = validationErrors; 28 | Error.captureStackTrace(this); 29 | } 30 | 31 | static badRequest(message: string, validationErrors?: ValidationType[]): AppError { 32 | return new AppError({ name: 'BadRequestError', message, statusCode: HttpCode.BAD_REQUEST, validationErrors }); 33 | } 34 | 35 | static unauthorized(message: string): AppError { 36 | return new AppError({ name: 'UnauthorizedError', message, statusCode: HttpCode.UNAUTHORIZED }); 37 | } 38 | 39 | static forbidden(message: string): AppError { 40 | return new AppError({ name: 'ForbiddenError', message, statusCode: HttpCode.FORBIDDEN }); 41 | } 42 | 43 | static notFound(message: string): AppError { 44 | return new AppError({ name: 'NotFoundError', message, statusCode: HttpCode.NOT_FOUND }); 45 | } 46 | 47 | static internalServer(message: string): AppError { 48 | return new AppError({ name: 'InternalServerError', message, statusCode: HttpCode.INTERNAL_SERVER_ERROR }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/core/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom.error'; 2 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | export * from './constants'; 3 | export * from './errors'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /src/core/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ValidationType { 2 | fields: string[]; 3 | constraint: string; 4 | } 5 | 6 | export interface SuccessResponse { 7 | data?: T; 8 | } 9 | 10 | export interface ErrorResponse { 11 | name: string; 12 | message: string; 13 | validationErrors?: ValidationType[]; 14 | stack?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/features/auth/domain/datasources/datasource.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/domain/datasources/datasource.ts 2 | 3 | import { type LoginUserDto, type RegisterUserDto } from '../dtos'; 4 | import { type UserEntity, type AuthEntity } from '../entities'; 5 | 6 | export abstract class AuthDatasource { 7 | abstract register(dto: RegisterUserDto): Promise; 8 | abstract login(dto: LoginUserDto): Promise; 9 | // TODO: create a DTO for this method 10 | abstract getUserById(dto: string): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/auth/domain/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register.dto'; 2 | export * from './login.dto'; 3 | -------------------------------------------------------------------------------- /src/features/auth/domain/dtos/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { AppError, type ValidationType, ZERO, REGEX_EMAIL, SIX } from '../../../../core'; 2 | import { type CoreDto } from '../../../shared'; 3 | 4 | /** 5 | * DTOs must have a validate method that throws an error 6 | * if the data is invalid or missing required fields. 7 | */ 8 | export class LoginUserDto implements CoreDto { 9 | private constructor( 10 | public readonly email: string, 11 | public readonly password: string 12 | ) { 13 | this.validate(this); 14 | } 15 | 16 | public validate(dto: LoginUserDto): void { 17 | const errors: ValidationType[] = []; 18 | const { email, password } = dto; 19 | 20 | if (!email || !REGEX_EMAIL.test(email)) { 21 | errors.push({ fields: ['email'], constraint: 'Email is not valid' }); 22 | } 23 | 24 | if (!password || password.length < SIX) { 25 | errors.push({ fields: ['password'], constraint: 'Password is not valid' }); 26 | } 27 | 28 | if (errors.length > ZERO) throw AppError.badRequest('Error validating user data', errors); 29 | } 30 | 31 | /** 32 | * This method creates a new instance of this DTO class with the given 33 | * properties from body or query parameters. 34 | * @param object 35 | * @returns A new instance of this DTO 36 | */ 37 | public static create(object: Record): LoginUserDto { 38 | const { email, password } = object; 39 | return new LoginUserDto(email as string, password as string); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/features/auth/domain/dtos/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { AppError, type ValidationType, ZERO, REGEX_EMAIL, SIX } from '../../../../core'; 2 | import { type CoreDto } from '../../../shared'; 3 | 4 | /** 5 | * DTOs must have a validate method that throws an error 6 | * if the data is invalid or missing required fields. 7 | */ 8 | export class RegisterUserDto implements CoreDto { 9 | private constructor( 10 | public readonly name: string, 11 | public readonly email: string, 12 | public readonly password: string 13 | ) { 14 | this.validate(this); 15 | } 16 | 17 | public validate(dto: RegisterUserDto): void { 18 | const errors: ValidationType[] = []; 19 | const { name, email, password } = dto; 20 | 21 | if (!name || name.length === ZERO) { 22 | errors.push({ fields: ['name'], constraint: 'Name is required' }); 23 | } 24 | 25 | if (!email || !REGEX_EMAIL.test(email)) { 26 | errors.push({ fields: ['email'], constraint: 'Email is not valid' }); 27 | } 28 | 29 | if (!password || password.length < SIX) { 30 | errors.push({ fields: ['password'], constraint: 'Password is not valid' }); 31 | } 32 | 33 | if (errors.length > ZERO) throw AppError.badRequest('Error validating user data', errors); 34 | } 35 | 36 | /** 37 | * This method creates a new instance of this DTO class with the given 38 | * properties from body or query parameters. 39 | * @param object 40 | * @returns A new instance of this DTO 41 | */ 42 | public static create(object: Record): RegisterUserDto { 43 | const { name, email, password } = object; 44 | return new RegisterUserDto(name as string, email as string, password as string); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/features/auth/domain/entities/auth.entity.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/domain/entities/auth.entity.ts 2 | 3 | import { type UserEntity } from './user.entity'; 4 | 5 | export class AuthEntity { 6 | constructor( 7 | public readonly user: Omit, 8 | public readonly token: string 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/features/auth/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.entity'; 2 | export * from './auth.entity'; 3 | -------------------------------------------------------------------------------- /src/features/auth/domain/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/domain/entities/user.entity.ts 2 | 3 | import { AppError, ZERO } from '../../../../core'; 4 | 5 | export class UserEntity { 6 | constructor( 7 | public id: string, 8 | public name: string, 9 | public email: string, 10 | public emailVerified: boolean = false, 11 | public password: string, 12 | public role: string[], 13 | public avatar?: string 14 | ) {} 15 | 16 | /** 17 | * If someone wants to work with this entity (map an object coming from my database), 18 | * let's make sure to do validations on its properties. … 19 | * @param obj - The object coming from my database 20 | * @returns UserEntity - A new UserEntity instance 21 | */ 22 | public static fromJson(obj: Record): UserEntity { 23 | const { id, name, email, emailVerified, password, role, avatar } = obj; 24 | if (!id) { 25 | throw AppError.badRequest('This entity requires an id', [{ constraint: 'id is required', fields: ['id'] }]); 26 | } 27 | if (!name || (name as string).length === ZERO) { 28 | throw AppError.badRequest('This entity requires a name', [{ constraint: 'name is required', fields: ['name'] }]); 29 | } 30 | if (!email || (email as string).length === ZERO) { 31 | throw AppError.badRequest('This entity requires an email', [ 32 | { constraint: 'email is required', fields: ['email'] } 33 | ]); 34 | } 35 | if (emailVerified === undefined) { 36 | throw AppError.badRequest('This entity requires an emailVerified', [ 37 | { constraint: 'emailVerified is required', fields: ['emailVerified'] } 38 | ]); 39 | } 40 | if (!password || (password as string).length === ZERO) { 41 | throw AppError.badRequest('This entity requires a password', [ 42 | { constraint: 'password is required', fields: ['password'] } 43 | ]); 44 | } 45 | if (!role || (role as string).length === ZERO) { 46 | throw AppError.badRequest('This entity requires a role', [{ constraint: 'role is required', fields: ['role'] }]); 47 | } 48 | return new UserEntity( 49 | id as string, 50 | name as string, 51 | email as string, 52 | emailVerified as boolean, 53 | password as string, 54 | role as string[], 55 | avatar as string 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/features/auth/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './dtos'; 3 | export * from './datasources/datasource'; 4 | export * from './repositories/repository'; 5 | export * from './usecases'; 6 | -------------------------------------------------------------------------------- /src/features/auth/domain/repositories/repository.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/domain/repositories/repository.ts 2 | 3 | import { type LoginUserDto, type RegisterUserDto } from '../dtos'; 4 | import { type UserEntity, type AuthEntity } from '../entities'; 5 | 6 | export abstract class AuthRepository { 7 | abstract register(dto: RegisterUserDto): Promise; 8 | abstract login(dto: LoginUserDto): Promise; 9 | // TODO: create a DTO for this method 10 | abstract getUserById(dto: string): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/auth/domain/usecases/getUserById.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type UserEntity } from '../entities'; 2 | import { type AuthRepository } from '../repositories/repository'; 3 | 4 | export interface GetUserByIdUseCase { 5 | execute: (dto: string) => Promise; 6 | } 7 | 8 | export class GetUserById implements GetUserByIdUseCase { 9 | constructor(private readonly repository: AuthRepository) {} 10 | 11 | async execute(dto: string): Promise { 12 | return await this.repository.getUserById(dto); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/features/auth/domain/usecases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register.usecase'; 2 | export * from './login.usecase'; 3 | export * from './getUserById.usecase'; 4 | -------------------------------------------------------------------------------- /src/features/auth/domain/usecases/login.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type LoginUserDto } from '../dtos'; 2 | import { type AuthEntity } from '../entities'; 3 | import { type AuthRepository } from '../repositories/repository'; 4 | 5 | export interface LoginUserUseCase { 6 | execute: (data: LoginUserDto) => Promise; 7 | } 8 | 9 | export class LoginUser implements LoginUserUseCase { 10 | constructor(private readonly repository: AuthRepository) {} 11 | 12 | async execute(data: LoginUserDto): Promise { 13 | return await this.repository.login(data); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/auth/domain/usecases/register.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type RegisterUserDto } from '../dtos'; 2 | import { type AuthEntity } from '../entities'; 3 | import { type AuthRepository } from '../repositories/repository'; 4 | 5 | export interface RegisterUserUseCase { 6 | execute: (data: RegisterUserDto) => Promise; 7 | } 8 | 9 | export class RegisterUser implements RegisterUserUseCase { 10 | constructor(private readonly repository: AuthRepository) {} 11 | 12 | async execute(data: RegisterUserDto): Promise { 13 | return await this.repository.register(data); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './presentation/controller'; 2 | export * from './presentation/routes'; 3 | export * from './presentation/middlewares'; 4 | export * from './domain'; 5 | export * from './infraestructure'; 6 | -------------------------------------------------------------------------------- /src/features/auth/infraestructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local.datasource.impl'; 2 | export * from './repository.impl'; 3 | -------------------------------------------------------------------------------- /src/features/auth/infraestructure/local.datasource.impl.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/infraestructure/local.datasource.impl.ts 2 | 3 | import { AppError, ONE, basicEncript, basicJWT } from '../../../core'; 4 | import { type RegisterUserDto, type AuthDatasource, UserEntity, AuthEntity, type LoginUserDto } from '../domain'; 5 | 6 | const USERS_MOCK = [ 7 | { 8 | id: '1', 9 | name: 'Test User', 10 | email: 'test@test.com', 11 | emailVerified: false, 12 | password: 'ca0711461f3b8387d01cc0c0cf532a4fb4b5fdf0207f7902fa75580718da497a', 13 | role: ['USER_ROLE'], 14 | avatar: 'https://avatars.dicebear.com/api/initials/T.svg' 15 | }, 16 | { 17 | id: '2', 18 | name: 'Test User 2', 19 | email: 'test2@test.com', 20 | emailVerified: false, 21 | password: 'ca0711461f3b8387d01cc0c0cf532a4fb4b5fdf0207f7902fa75580718da497a', 22 | role: ['USER_ROLE'] 23 | } 24 | ]; 25 | 26 | export class AuthDatasourceImpl implements AuthDatasource { 27 | public async register(dto: RegisterUserDto): Promise { 28 | const user = USERS_MOCK.find((user) => user.email === dto.email); 29 | if (user) { 30 | throw AppError.badRequest('User already exists', [{ constraint: 'User already exists', fields: ['email'] }]); 31 | } 32 | const createdUser = { 33 | ...dto, 34 | id: (USERS_MOCK.length + ONE).toString(), 35 | emailVerified: false, 36 | role: ['USER_ROLE'] 37 | }; 38 | // Hash the password 39 | createdUser.password = basicEncript.hashPassword(dto.password); 40 | // Add the user to the mock 41 | USERS_MOCK.push(createdUser); 42 | // Create the auth entity (omit the password) 43 | const { password, ...rest } = UserEntity.fromJson(createdUser); 44 | const token = basicJWT.generateToken({ id: createdUser.id }); 45 | // ? Here you can verify if the token is created correctly before to send it to the client 46 | return new AuthEntity(rest, token); 47 | } 48 | 49 | public async login(dto: LoginUserDto): Promise { 50 | const user = USERS_MOCK.find((user) => user.email === dto.email); 51 | if (!user) throw AppError.badRequest('User with this email not found'); 52 | const isPasswordMatch = basicEncript.comparePassword(dto.password, user.password); 53 | if (!isPasswordMatch) throw AppError.badRequest('Invalid password'); 54 | const { password, ...rest } = UserEntity.fromJson({ ...user }); 55 | const token = basicJWT.generateToken({ id: user.id }); 56 | // ? Here you can verify if the token is created correctly before to send it to the client 57 | return new AuthEntity(rest, token); 58 | } 59 | 60 | public async getUserById(dto: string): Promise { 61 | const user = USERS_MOCK.find((user) => user.id === dto); 62 | if (!user) throw AppError.badRequest('User with this id not found'); 63 | return UserEntity.fromJson({ ...user }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/features/auth/infraestructure/repository.impl.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/infraestructure/repository.impl.ts 2 | 3 | import { 4 | type RegisterUserDto, 5 | type AuthRepository, 6 | type AuthEntity, 7 | type AuthDatasource, 8 | type LoginUserDto, 9 | type UserEntity 10 | } from '../domain'; 11 | 12 | export class AuthRepositoryImpl implements AuthRepository { 13 | constructor(private readonly datasource: AuthDatasource) {} 14 | 15 | public async register(dto: RegisterUserDto): Promise { 16 | return await this.datasource.register(dto); 17 | } 18 | 19 | public async login(dto: LoginUserDto): Promise { 20 | return await this.datasource.login(dto); 21 | } 22 | 23 | public async getUserById(dto: string): Promise { 24 | return await this.datasource.getUserById(dto); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/features/auth/presentation/controller.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/presentation/controller.ts 2 | 3 | import { type NextFunction, type Request, type Response } from 'express'; 4 | 5 | import { HttpCode, type SuccessResponse } from '../../../core'; 6 | import { 7 | type AuthRepository, 8 | RegisterUserDto, 9 | LoginUser, 10 | type AuthEntity, 11 | RegisterUser, 12 | LoginUserDto 13 | } from '../domain'; 14 | 15 | interface RequestBodyLogin { 16 | email: string; 17 | password: string; 18 | } 19 | 20 | interface RequestBodyRegister { 21 | name: string; 22 | email: string; 23 | password: string; 24 | } 25 | 26 | export class AuthController { 27 | //* Dependency injection 28 | constructor(private readonly repository: AuthRepository) {} 29 | 30 | public login = ( 31 | req: Request, 32 | res: Response>, 33 | next: NextFunction 34 | ): void => { 35 | const { email, password } = req.body; 36 | const dto = LoginUserDto.create({ email, password }); 37 | new LoginUser(this.repository) 38 | .execute(dto) 39 | .then((result) => res.json({ data: result })) 40 | .catch(next); 41 | }; 42 | 43 | public register = ( 44 | req: Request, 45 | res: Response>, 46 | next: NextFunction 47 | ): void => { 48 | const { email, name, password } = req.body; 49 | const dto = RegisterUserDto.create({ email, name, password }); 50 | new RegisterUser(this.repository) 51 | .execute(dto) 52 | .then((result) => res.status(HttpCode.CREATED).json({ data: result })) 53 | .catch(next); 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/features/auth/presentation/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { type Response, type NextFunction, type Request } from 'express'; 2 | import { AppError, ONE, basicJWT } from '../../../../core'; 3 | 4 | import { type AuthRepository, GetUserById } from '../../../auth'; 5 | 6 | export class AuthMiddleware { 7 | //* Dependency injection 8 | constructor(private readonly repository: AuthRepository) {} 9 | 10 | public validateJWT = (req: Request, _: Response, next: NextFunction): void => { 11 | const authorization = req.header('Authorization'); 12 | 13 | if (!authorization) throw AppError.unauthorized('Unauthorized (no authorization header)'); 14 | 15 | if (!authorization.startsWith('Bearer ')) { 16 | throw AppError.unauthorized('Invalid authorization header (Bearer token required)'); 17 | } 18 | 19 | const token = authorization.split(' ').at(ONE) ?? ''; 20 | const payload = basicJWT.validateToken<{ id: string }>(token); 21 | 22 | if (!payload) throw AppError.unauthorized('Invalid token'); 23 | 24 | new GetUserById(this.repository) 25 | .execute(payload.id) 26 | .then((result) => { 27 | req.body.user = result; 28 | next(); 29 | }) 30 | .catch(next); 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/features/auth/presentation/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.middleware'; 2 | -------------------------------------------------------------------------------- /src/features/auth/presentation/routes.ts: -------------------------------------------------------------------------------- 1 | // src/features/auth/presentation/routes.ts 2 | 3 | import { Router } from 'express'; 4 | 5 | import { AuthController } from './controller'; 6 | import { AuthDatasourceImpl, AuthRepositoryImpl } from '../infraestructure'; 7 | 8 | export class AuthRoutes { 9 | static get routes(): Router { 10 | const router = Router(); 11 | 12 | const datasource = new AuthDatasourceImpl(); 13 | const repository = new AuthRepositoryImpl(datasource); 14 | const controller = new AuthController(repository); 15 | 16 | router.post('/login', controller.login); 17 | router.post('/register', controller.register); 18 | 19 | return router; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/features/shared/domain/dtos/core.dto.ts: -------------------------------------------------------------------------------- 1 | export abstract class CoreDto { 2 | abstract validate(dto: T): void; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/shared/domain/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination.dto'; 2 | export * from './core.dto'; 3 | -------------------------------------------------------------------------------- /src/features/shared/domain/dtos/pagination.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../../core'; 2 | import { PaginationDto } from './pagination.dto'; 3 | 4 | describe('tests in pagination.dto.ts', () => { 5 | test('should create an instance with valid page and limit', () => { 6 | const dto = PaginationDto.create({ page: 1, limit: 10 }); 7 | expect(dto.page).toBe(1); 8 | expect(dto.limit).toBe(10); 9 | }); 10 | 11 | test('should throw a validation error for page or limit invalid', () => { 12 | expect(() => PaginationDto.create({ page: NaN, limit: 10 })).toThrow(AppError); 13 | expect(() => PaginationDto.create({ page: 1, limit: NaN })).toThrow(AppError); 14 | expect(() => PaginationDto.create({ page: 0, limit: 10 })).toThrow(AppError); 15 | expect(() => PaginationDto.create({ page: -1, limit: 10 })).toThrow(AppError); 16 | expect(() => PaginationDto.create({ page: 1, limit: 0 })).toThrow(AppError); 17 | expect(() => PaginationDto.create({ page: 1, limit: -1 })).toThrow(AppError); 18 | }); 19 | 20 | test('should throw a validation error with correct error message for NaN values', () => { 21 | try { 22 | PaginationDto.create({ page: NaN, limit: NaN }); 23 | } catch (error) { 24 | if (error instanceof AppError) { 25 | expect(error.validationErrors).toEqual([ 26 | { fields: ['page', 'limit'], constraint: 'Page and limit must be numbers' } 27 | ]); 28 | } 29 | } 30 | }); 31 | 32 | test('should throw a validation error with correct error message for page less than or equal to zero', () => { 33 | try { 34 | PaginationDto.create({ page: 0, limit: 10 }); 35 | } catch (error) { 36 | if (error instanceof AppError) { 37 | expect(error.validationErrors).toEqual([{ fields: ['page'], constraint: 'Page must be greater than zero' }]); 38 | } 39 | } 40 | 41 | try { 42 | PaginationDto.create({ page: -1, limit: 10 }); 43 | } catch (error) { 44 | if (error instanceof AppError) { 45 | expect(error.validationErrors).toEqual([{ fields: ['page'], constraint: 'Page must be greater than zero' }]); 46 | } 47 | } 48 | }); 49 | 50 | test('should throw a validation error with correct error message for limit less than or equal to zero', () => { 51 | try { 52 | PaginationDto.create({ page: 1, limit: 0 }); 53 | } catch (error) { 54 | if (error instanceof AppError) { 55 | expect(error.validationErrors).toEqual([{ fields: ['limit'], constraint: 'Limit must be greater than zero' }]); 56 | } 57 | } 58 | 59 | try { 60 | PaginationDto.create({ page: 1, limit: -1 }); 61 | } catch (error) { 62 | if (error instanceof AppError) { 63 | expect(error.validationErrors).toEqual([{ fields: ['limit'], constraint: 'Limit must be greater than zero' }]); 64 | } 65 | } 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/features/shared/domain/dtos/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { AppError, ZERO, type ValidationType } from '../../../../core'; 2 | import { type CoreDto } from './core.dto'; 3 | 4 | export class PaginationDto implements CoreDto { 5 | private constructor( 6 | public readonly page: number, 7 | public readonly limit: number 8 | ) { 9 | this.validate(this); 10 | } 11 | 12 | /** 13 | * This method validates the properties of the PaginationDto class. 14 | * @param dto The instance of the PaginationDto class to be validated. 15 | * @returns void 16 | */ 17 | public validate(dto: PaginationDto): void { 18 | const errors: ValidationType[] = []; 19 | 20 | if (isNaN(dto.page) || isNaN(dto.limit)) { 21 | errors.push({ fields: ['page', 'limit'], constraint: 'Page and limit must be numbers' }); 22 | } 23 | 24 | if (dto.page <= ZERO) { 25 | errors.push({ fields: ['page'], constraint: 'Page must be greater than zero' }); 26 | } 27 | 28 | if (dto.limit <= ZERO) { 29 | errors.push({ fields: ['limit'], constraint: 'Limit must be greater than zero' }); 30 | } 31 | 32 | if (errors.length > ZERO) throw AppError.badRequest('Error validating pagination', errors); 33 | } 34 | 35 | /** 36 | * This method creates a new instance of this DTO class with the given 37 | * properties from body or query parameters. 38 | * @param object 39 | * @returns A new instance of this DTO 40 | */ 41 | public static create(object: Record): PaginationDto { 42 | const { page, limit } = object; 43 | return new PaginationDto(page as number, limit as number); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/features/shared/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paginationResponse.entity'; 2 | -------------------------------------------------------------------------------- /src/features/shared/domain/entities/paginationResponse.entity.test.ts: -------------------------------------------------------------------------------- 1 | import { PaginationResponseEntity } from './paginationResponse.entity'; 2 | 3 | describe('tests in paginationResponse.entity.test.ts', () => { 4 | test('should create an instance with given values', () => { 5 | const total = 100; 6 | const totalPages = 10; 7 | const currentPage = 1; 8 | const nextPage = 2; 9 | const prevPage = null; 10 | const results = [{ id: 1, name: 'Test Item' }]; 11 | 12 | const entity = new PaginationResponseEntity(total, totalPages, currentPage, nextPage, prevPage, results); 13 | 14 | expect(entity.total).toBe(total); 15 | expect(entity.totalPages).toBe(totalPages); 16 | expect(entity.currentPage).toBe(currentPage); 17 | expect(entity.nextPage).toBe(nextPage); 18 | expect(entity.prevPage).toBe(prevPage); 19 | expect(entity.results).toBe(results); 20 | }); 21 | 22 | it('should allow nextPage and prevPage to be null', () => { 23 | const total = 100; 24 | const totalPages = 10; 25 | const currentPage = 1; 26 | const nextPage = null; 27 | const prevPage = null; 28 | const results = [{ id: 1, name: 'Test Item' }]; 29 | 30 | const entity = new PaginationResponseEntity(total, totalPages, currentPage, nextPage, prevPage, results); 31 | 32 | expect(entity.nextPage).toBeNull(); 33 | expect(entity.prevPage).toBeNull(); 34 | }); 35 | 36 | it('should correctly handle different types for results', () => { 37 | const total = 100; 38 | const totalPages = 10; 39 | const currentPage = 1; 40 | const nextPage = 2; 41 | const prevPage = null; 42 | const results = [{ id: 1, name: 'Test Item' }]; 43 | 44 | const entity = new PaginationResponseEntity(total, totalPages, currentPage, nextPage, prevPage, results); 45 | 46 | expect(Array.isArray(entity.results)).toBe(true); 47 | }); 48 | 49 | it('should create an instance with a different type for results', () => { 50 | const total = 100; 51 | const totalPages = 10; 52 | const currentPage = 1; 53 | const nextPage = 2; 54 | const prevPage = null; 55 | const results = { id: 1, name: 'Test Item' }; 56 | 57 | const entity = new PaginationResponseEntity(total, totalPages, currentPage, nextPage, prevPage, results); 58 | 59 | expect(entity.results).toEqual(results); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/features/shared/domain/entities/paginationResponse.entity.ts: -------------------------------------------------------------------------------- 1 | export class PaginationResponseEntity { 2 | constructor( 3 | public total: number, 4 | public totalPages: number, 5 | public currentPage: number, 6 | public nextPage: number | null, 7 | public prevPage: number | null, 8 | public results: T 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/features/shared/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dtos'; 2 | export * from './entities'; 3 | -------------------------------------------------------------------------------- /src/features/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain'; 2 | export * from './presentation'; 3 | -------------------------------------------------------------------------------- /src/features/shared/presentation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './middlewares'; 2 | -------------------------------------------------------------------------------- /src/features/shared/presentation/middlewares/custom.middleware.ts: -------------------------------------------------------------------------------- 1 | import { type Response, type NextFunction, type Request } from 'express'; 2 | 3 | export class CustomMiddlewares { 4 | //* Dependency injection 5 | // constructor() {} 6 | 7 | public static writeInConsole = (_req: Request, _res: Response, next: NextFunction): void => { 8 | // console.log('Hello from the Middleware'); 9 | next(); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/shared/presentation/middlewares/error.middleware.ts: -------------------------------------------------------------------------------- 1 | import { type Response, type NextFunction, type Request } from 'express'; 2 | 3 | import { type ErrorResponse, HttpCode, AppError } from '../../../../core'; 4 | 5 | export class ErrorMiddleware { 6 | //* Dependency injection 7 | // constructor() {} 8 | 9 | public static handleError = (error: unknown, _: Request, res: Response, next: NextFunction): void => { 10 | if (error instanceof AppError) { 11 | const { message, name, stack, validationErrors } = error; 12 | const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR; 13 | res.statusCode = statusCode; 14 | res.json({ name, message, validationErrors, stack }); 15 | } else { 16 | const name = 'InternalServerError'; 17 | const message = 'An internal server error occurred'; 18 | const statusCode = HttpCode.INTERNAL_SERVER_ERROR; 19 | res.statusCode = statusCode; 20 | res.json({ name, message }); 21 | } 22 | next(); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/features/shared/presentation/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom.middleware'; 2 | export * from './error.middleware'; 3 | -------------------------------------------------------------------------------- /src/features/todos/domain/datasources/datasource.test.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto, PaginationResponseEntity } from '../../../shared'; 2 | import { CreateTodoDto, GetTodoByIdDto, UpdateTodoDto } from '../dtos'; 3 | import { TodoEntity } from '../entities'; 4 | import { TodoDatasource } from './datasource'; 5 | 6 | describe('tests in datasource.test.ts', () => { 7 | const todo = new TodoEntity(1, 'Test', false); 8 | 9 | class MockDatasource implements TodoDatasource { 10 | async create(createDto: CreateTodoDto): Promise { 11 | return todo; 12 | } 13 | 14 | async getAll(pagination: PaginationDto): Promise> { 15 | return { 16 | results: [todo], 17 | currentPage: 1, 18 | nextPage: null, 19 | prevPage: null, 20 | total: 1, 21 | totalPages: 1 22 | }; 23 | } 24 | 25 | async getById(getByIdDto: GetTodoByIdDto): Promise { 26 | return todo; 27 | } 28 | 29 | async update(updateDto: UpdateTodoDto): Promise { 30 | return todo; 31 | } 32 | 33 | async delete(getByIdDto: GetTodoByIdDto): Promise { 34 | return todo; 35 | } 36 | } 37 | 38 | test('should test be abstract class', async () => { 39 | const mockDatasource = new MockDatasource(); 40 | 41 | expect(mockDatasource).toBeInstanceOf(MockDatasource); 42 | 43 | expect(typeof mockDatasource.create).toBe('function'); 44 | expect(typeof mockDatasource.getAll).toBe('function'); 45 | expect(typeof mockDatasource.getById).toBe('function'); 46 | expect(typeof mockDatasource.update).toBe('function'); 47 | expect(typeof mockDatasource.delete).toBe('function'); 48 | 49 | const todos = await mockDatasource.getAll(PaginationDto.create({ page: 1, limit: 10 })); 50 | expect(todos.results).toHaveLength(1); 51 | expect(todos.results).toBeInstanceOf(Array); 52 | expect(todos.results[0]).toBeInstanceOf(TodoEntity); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/features/todos/domain/datasources/datasource.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\domain\datasources\datasource.ts 2 | 3 | import { type PaginationDto, type PaginationResponseEntity } from '../../../shared'; 4 | import { type UpdateTodoDto, type CreateTodoDto, type GetTodoByIdDto } from '../dtos'; 5 | import { type TodoEntity } from '../entities'; 6 | 7 | export abstract class TodoDatasource { 8 | abstract create(createDto: CreateTodoDto): Promise; 9 | abstract getAll(pagination: PaginationDto): Promise>; 10 | abstract getById(getByIdDto: GetTodoByIdDto): Promise; 11 | abstract update(updateDto: UpdateTodoDto): Promise; 12 | abstract delete(getByIdDto: GetTodoByIdDto): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/create.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../../core'; 2 | import { CreateTodoDto } from './create.dto'; 3 | 4 | describe('tests in create.dto.ts', () => { 5 | test('should create a valid CreateTodoDto', () => { 6 | const todoData = CreateTodoDto.create({ text: 'Valid Todo' }); 7 | expect(todoData).toBeInstanceOf(CreateTodoDto); 8 | expect(todoData.text).toBe('Valid Todo'); 9 | }); 10 | 11 | test('should throw a validation error if text is empty', () => { 12 | expect(() => CreateTodoDto.create({ text: '' })).toThrow(AppError); 13 | }); 14 | 15 | test('should throw a validation error if text is not provided', () => { 16 | expect(() => CreateTodoDto.create({ text: undefined as unknown as string })).toThrow(AppError); 17 | }); 18 | 19 | test('should include the correct validation error message if text is empty', () => { 20 | try { 21 | CreateTodoDto.create({ text: '' }); 22 | } catch (error) { 23 | expect(error).toBeInstanceOf(AppError); 24 | if (error instanceof AppError) { 25 | expect(error.validationErrors).toEqual([{ fields: ['text'], constraint: 'Text is required' }]); 26 | } 27 | } 28 | }); 29 | 30 | test('should include the correct validation error message if text is not provided', () => { 31 | try { 32 | CreateTodoDto.create({ text: undefined as unknown as string }); 33 | } catch (error) { 34 | expect(error).toBeInstanceOf(AppError); 35 | if (error instanceof AppError) { 36 | expect(error.validationErrors).toEqual([{ fields: ['text'], constraint: 'Text is required' }]); 37 | } 38 | } 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/create.dto.ts: -------------------------------------------------------------------------------- 1 | import { type ValidationType, AppError, ZERO } from '../../../../core'; 2 | import { type CoreDto } from '../../../shared'; 3 | 4 | export class CreateTodoDto implements CoreDto { 5 | private constructor(public readonly text: string) { 6 | this.validate(this); 7 | } 8 | 9 | public validate(dto: CreateTodoDto): void { 10 | const errors: ValidationType[] = []; 11 | 12 | if (!dto.text || dto.text.length === ZERO) { 13 | errors.push({ fields: ['text'], constraint: 'Text is required' }); 14 | } 15 | 16 | if (errors.length > ZERO) throw AppError.badRequest('Error validating create todo', errors); 17 | } 18 | 19 | /** 20 | * This method creates a new instance of this DTO class with the given 21 | * properties from body or query parameters. 22 | * @param object 23 | * @returns A new instance of this DTO 24 | */ 25 | public static create(object: Record): CreateTodoDto { 26 | const { text } = object; 27 | return new CreateTodoDto(text as string); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/getById.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../../core'; 2 | import { GetTodoByIdDto } from './getById.dto'; 3 | 4 | describe('tests in getById.dto.ts', () => { 5 | test('should create an instance with a valid id', () => { 6 | const dto = GetTodoByIdDto.create({ id: 1 }); 7 | expect(dto.id).toBe(1); 8 | }); 9 | 10 | test('should throw a validation error for an invalid id', () => { 11 | expect(() => GetTodoByIdDto.create({ id: NaN })).toThrow(AppError); 12 | expect(() => GetTodoByIdDto.create({ id: null })).toThrow(AppError); 13 | expect(() => GetTodoByIdDto.create({ id: undefined })).toThrow(AppError); 14 | expect(() => GetTodoByIdDto.create({ id: 'invalid' })).toThrow(AppError); 15 | }); 16 | 17 | test('should throw a validation error with correct error message for invalid id', () => { 18 | try { 19 | GetTodoByIdDto.create({ id: NaN }); 20 | // GetTodoByIdDto.create({id: null}); 21 | // GetTodoByIdDto.create({id: undefined}); 22 | // GetTodoByIdDto.create({id: 'invalid'}); 23 | } catch (error) { 24 | if (error instanceof AppError) { 25 | expect(error.validationErrors).toEqual([{ fields: ['id'], constraint: 'Id is not a valid number' }]); 26 | } 27 | } 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/getById.dto.ts: -------------------------------------------------------------------------------- 1 | import { type ValidationType, ZERO, AppError } from '../../../../core'; 2 | import { type CoreDto } from '../../../shared'; 3 | 4 | export class GetTodoByIdDto implements CoreDto { 5 | private constructor(public readonly id: number) { 6 | this.validate(this); 7 | } 8 | 9 | public validate(dto: GetTodoByIdDto): void { 10 | const errors: ValidationType[] = []; 11 | 12 | const { id } = dto; 13 | 14 | if (!id || isNaN(Number(id))) { 15 | errors.push({ fields: ['id'], constraint: 'Id is not a valid number' }); 16 | } 17 | 18 | if (errors.length > ZERO) throw AppError.badRequest('Error validating get todo by id', errors); 19 | } 20 | 21 | /** 22 | * This method creates a new instance of the DTO class with the given 23 | * properties from body or query parameters. 24 | * @param object 25 | * @returns A new instance of the DTO 26 | */ 27 | public static create(object: Record): GetTodoByIdDto { 28 | const { id } = object; 29 | return new GetTodoByIdDto(id as number); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.dto'; 2 | export * from './update.dto'; 3 | export * from './getById.dto'; 4 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/update.dto.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../../core'; 2 | import { UpdateTodoDto } from './update.dto'; 3 | 4 | describe('tests in update.dto.test.ts', () => { 5 | test('should create an instance with valid id and isCompleted', () => { 6 | const dto = UpdateTodoDto.create({ id: 1, isCompleted: true }); 7 | expect(dto.id).toBe(1); 8 | expect(dto.isCompleted).toBe(true); 9 | }); 10 | 11 | test('should create an instance with valid id, text, and isCompleted', () => { 12 | const dto = UpdateTodoDto.create({ id: 1, text: 'Test todo', isCompleted: false }); 13 | expect(dto.id).toBe(1); 14 | expect(dto.text).toBe('Test todo'); 15 | expect(dto.isCompleted).toBe(false); 16 | }); 17 | 18 | test('should throw a validation error for an invalid id', () => { 19 | expect(() => UpdateTodoDto.create({ id: NaN, isCompleted: true })).toThrow(AppError); 20 | expect(() => UpdateTodoDto.create({ id: null, isCompleted: true })).toThrow(AppError); 21 | expect(() => UpdateTodoDto.create({ id: undefined, isCompleted: true })).toThrow(AppError); 22 | expect(() => UpdateTodoDto.create({ id: 'invalid', isCompleted: true })).toThrow(AppError); 23 | }); 24 | 25 | test('should throw a validation error for an invalid isCompleted (not a boolean)', () => { 26 | expect(() => UpdateTodoDto.create({ id: 1, isCompleted: 'invalid' })).toThrow(AppError); 27 | }); 28 | 29 | test('should throw a validation error with correct error message for invalid id', () => { 30 | try { 31 | UpdateTodoDto.create({ id: NaN, isCompleted: true }); 32 | } catch (error) { 33 | if (error instanceof AppError) { 34 | expect(error.validationErrors).toEqual([{ fields: ['id'], constraint: 'Id is not a valid number' }]); 35 | } 36 | } 37 | }); 38 | 39 | test('should throw a validation error with correct error message for invalid isCompleted', () => { 40 | try { 41 | UpdateTodoDto.create({ id: 1, isCompleted: 'invalid' }); 42 | } catch (error) { 43 | if (error instanceof AppError) { 44 | expect(error.validationErrors).toEqual([ 45 | { fields: ['isCompleted'], constraint: 'isCompleted must be a valid value (true or false)' } 46 | ]); 47 | } 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/features/todos/domain/dtos/update.dto.ts: -------------------------------------------------------------------------------- 1 | import { type ValidationType, AppError, ZERO } from '../../../../core'; 2 | import { type CoreDto } from '../../../shared'; 3 | 4 | export class UpdateTodoDto implements CoreDto { 5 | private constructor( 6 | public readonly id: number, 7 | public readonly text?: string, 8 | public readonly isCompleted?: boolean 9 | ) { 10 | this.validate(this); 11 | } 12 | 13 | public validate(dto: UpdateTodoDto): void { 14 | const errors: ValidationType[] = []; 15 | 16 | const { id, isCompleted } = dto; 17 | 18 | if (!id || isNaN(Number(id))) { 19 | errors.push({ fields: ['id'], constraint: 'Id is not a valid number' }); 20 | } 21 | 22 | if ( 23 | isCompleted !== undefined && 24 | typeof isCompleted !== 'boolean' && 25 | isCompleted !== 'true' && 26 | isCompleted !== 'false' 27 | ) { 28 | errors.push({ fields: ['isCompleted'], constraint: 'isCompleted must be a valid value (true or false)' }); 29 | } 30 | 31 | if (errors.length > ZERO) throw AppError.badRequest('Error validating update todo', errors); 32 | } 33 | 34 | /** 35 | * This method creates a new instance of the DTO class with the given 36 | * properties from body or query parameters. 37 | * @param object 38 | * @returns A new instance of the DTO 39 | */ 40 | public static create(object: Record): UpdateTodoDto { 41 | const { id, text, isCompleted } = object; 42 | return new UpdateTodoDto(id as number, text as string, isCompleted as boolean); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/features/todos/domain/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './todo.entity'; 2 | -------------------------------------------------------------------------------- /src/features/todos/domain/entities/todo.entity.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../../core'; 2 | import { TodoEntity } from './todo.entity'; 3 | 4 | describe('tests in todo.entity.test.ts', () => { 5 | test('should create todo entity instance', () => { 6 | const todo = new TodoEntity(1, 'Test'); 7 | expect(todo).toBeInstanceOf(TodoEntity); 8 | expect(todo.id).toBe(1); 9 | expect(todo.text).toBe('Test'); 10 | expect(todo.isCompleted).toBe(false); 11 | }); 12 | 13 | test('should create a Todo entity instance from json', () => { 14 | const todo = TodoEntity.fromJson({ id: 1, text: 'Test' }); 15 | expect(todo).toBeInstanceOf(TodoEntity); 16 | expect(todo.id).toBe(1); 17 | expect(todo.text).toBe('Test'); 18 | expect(todo.isCompleted).toBe(false); 19 | }); 20 | 21 | test('should throw validation error', () => { 22 | // ? Shrotest to throw AppError 23 | // expect(() => TodoEntity.fromJson({ id: 1 })).toThrow(AppError); 24 | 25 | expect(() => TodoEntity.fromJson({ id: 1 })).toThrow( 26 | AppError.badRequest('This entity requires a text', [{ constraint: 'text is required', fields: ['text'] }]) 27 | ); 28 | expect(() => TodoEntity.fromJson({ text: 'Hola' })).toThrow( 29 | AppError.badRequest('This entity requires an id', [{ constraint: 'id is required', fields: ['id'] }]) 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/features/todos/domain/entities/todo.entity.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\domain\entities\todo.entity.ts 2 | 3 | import { AppError, ZERO } from '../../../../core'; 4 | 5 | export class TodoEntity { 6 | constructor( 7 | public id: number, 8 | public text: string, 9 | public isCompleted: boolean = false 10 | ) {} 11 | 12 | public static fromJson(obj: Record): TodoEntity { 13 | const { id, text, isCompleted = false } = obj; 14 | if (!id) { 15 | throw AppError.badRequest('This entity requires an id', [{ constraint: 'id is required', fields: ['id'] }]); 16 | } 17 | if (!text || (text as string).length === ZERO) { 18 | throw AppError.badRequest('This entity requires a text', [{ constraint: 'text is required', fields: ['text'] }]); 19 | } 20 | return new TodoEntity(id as number, text as string, isCompleted as boolean); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/features/todos/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from './datasources/datasource'; 2 | export * from './dtos'; 3 | export * from './entities'; 4 | export * from './repositories/respository'; 5 | export * from './usecases'; 6 | -------------------------------------------------------------------------------- /src/features/todos/domain/repositories/respository.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\domain\repositories\respository.ts 2 | 3 | import { type PaginationDto, type PaginationResponseEntity } from '../../../shared'; 4 | import { type GetTodoByIdDto, type UpdateTodoDto, type CreateTodoDto } from '../dtos'; 5 | import { type TodoEntity } from '../entities'; 6 | 7 | export abstract class TodoRepository { 8 | abstract create(createDto: CreateTodoDto): Promise; 9 | abstract getAll(pagination: PaginationDto): Promise>; 10 | abstract getById(getByIdDto: GetTodoByIdDto): Promise; 11 | abstract update(updateDto: UpdateTodoDto): Promise; 12 | abstract delete(getByIdDto: GetTodoByIdDto): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/create.usecase.test.ts: -------------------------------------------------------------------------------- 1 | import { CreateTodoDto } from '../dtos'; 2 | import { TodoEntity } from '../entities'; 3 | import { TodoRepository } from '../repositories/respository'; 4 | import { CreateTodo, CreateTodoUseCase } from './create.usecase'; 5 | 6 | describe('tests in create.usecase.ts', () => { 7 | let repository: jest.Mocked; 8 | let createTodoUseCase: CreateTodoUseCase; 9 | 10 | beforeEach(() => { 11 | repository = { 12 | create: jest.fn(), 13 | getAll: jest.fn(), 14 | getById: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn() 17 | } as jest.Mocked; 18 | 19 | createTodoUseCase = new CreateTodo(repository); 20 | }); 21 | 22 | test('should create a todo item successfully', async () => { 23 | const todoData = CreateTodoDto.create({ text: 'Test Todo' }); 24 | const createdTodo: TodoEntity = { id: 1, text: 'Test Todo', isCompleted: false }; 25 | 26 | repository.create.mockResolvedValue(createdTodo); 27 | 28 | const result = await createTodoUseCase.execute(todoData); 29 | 30 | expect(repository.create).toHaveBeenCalledWith(todoData); 31 | expect(result).toEqual(createdTodo); 32 | }); 33 | 34 | test('should throw an error if repository.create fails', async () => { 35 | const todoData = CreateTodoDto.create({ text: 'Test Todo' }); 36 | const error = new Error('Repository create failed'); 37 | 38 | repository.create.mockRejectedValue(error); 39 | 40 | await expect(createTodoUseCase.execute(todoData)).rejects.toThrow(error); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/create.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type CreateTodoDto } from '../dtos'; 2 | import { type TodoEntity } from '../entities'; 3 | import { type TodoRepository } from '../repositories/respository'; 4 | 5 | export interface CreateTodoUseCase { 6 | execute: (data: CreateTodoDto) => Promise; 7 | } 8 | 9 | export class CreateTodo implements CreateTodoUseCase { 10 | constructor(private readonly repository: TodoRepository) {} 11 | 12 | async execute(data: CreateTodoDto): Promise { 13 | return await this.repository.create(data); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/delete.usecase.test.ts: -------------------------------------------------------------------------------- 1 | import { GetTodoByIdDto } from '../dtos'; 2 | import { TodoEntity } from '../entities'; 3 | import { TodoRepository } from '../repositories/respository'; 4 | import { DeleteTodo, DeleteTodoUseCase } from './delete.usecase'; 5 | 6 | describe('tests in delete.usecase.ts', () => { 7 | let repository: jest.Mocked; 8 | let deleteTodoUseCase: DeleteTodoUseCase; 9 | 10 | beforeEach(() => { 11 | repository = { 12 | create: jest.fn(), 13 | getAll: jest.fn(), 14 | getById: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn() 17 | } as jest.Mocked; 18 | 19 | deleteTodoUseCase = new DeleteTodo(repository); 20 | }); 21 | 22 | test('should call repository.delete with correct parameters', async () => { 23 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 24 | const todoEntity = new TodoEntity(getByIdDto.id, 'Test Todo'); 25 | 26 | repository.delete.mockResolvedValue(todoEntity); 27 | 28 | const result = await deleteTodoUseCase.execute(getByIdDto); 29 | 30 | expect(repository.delete).toHaveBeenCalledWith(getByIdDto); 31 | expect(result).toBe(todoEntity); 32 | }); 33 | 34 | test('should throw an error if repository.delete fails', async () => { 35 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 36 | const errorMessage = 'Delete failed'; 37 | 38 | repository.delete.mockRejectedValue(new Error(errorMessage)); 39 | 40 | await expect(deleteTodoUseCase.execute(getByIdDto)).rejects.toThrow(errorMessage); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/delete.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type GetTodoByIdDto } from '../dtos'; 2 | import { type TodoEntity } from '../entities'; 3 | import { type TodoRepository } from '../repositories/respository'; 4 | 5 | export interface DeleteTodoUseCase { 6 | execute: (getByIdDto: GetTodoByIdDto) => Promise; 7 | } 8 | 9 | export class DeleteTodo implements DeleteTodoUseCase { 10 | constructor(private readonly repository: TodoRepository) {} 11 | 12 | async execute(getByIdDto: GetTodoByIdDto): Promise { 13 | return await this.repository.delete(getByIdDto); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/getAll.usecase.test.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from '../../../shared'; 2 | import { TodoEntity } from '../entities'; 3 | import { TodoRepository } from '../repositories/respository'; 4 | import { GetTodos, GetTodosUseCase } from './getAll.usecase'; 5 | 6 | describe('tests in getAll.usecase.ts', () => { 7 | let repository: jest.Mocked; 8 | let getTodosUseCase: GetTodosUseCase; 9 | 10 | beforeEach(() => { 11 | repository = { 12 | create: jest.fn(), 13 | getAll: jest.fn(), 14 | getById: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn() 17 | } as jest.Mocked; 18 | 19 | getTodosUseCase = new GetTodos(repository); 20 | }); 21 | 22 | test('should call repository.getAll with correct parameters', async () => { 23 | const paginationDto = PaginationDto.create({ page: 1, limit: 10 }); 24 | 25 | const paginationResult = { 26 | results: [new TodoEntity(1, 'Test Todo')], 27 | currentPage: 1, 28 | nextPage: null, 29 | prevPage: null, 30 | total: 1, 31 | totalPages: 1 32 | }; 33 | 34 | repository.getAll.mockResolvedValue(paginationResult); 35 | 36 | const result = await getTodosUseCase.execute(paginationDto); 37 | 38 | expect(repository.getAll).toHaveBeenCalledWith(paginationDto); 39 | expect(result).toBe(paginationResult); 40 | }); 41 | 42 | test('should throw an error if repository.getAll fails', async () => { 43 | const paginationDto = PaginationDto.create({ page: 1, limit: 10 }); 44 | const errorMessage = 'GetAll failed'; 45 | 46 | repository.getAll.mockRejectedValue(new Error(errorMessage)); 47 | 48 | await expect(getTodosUseCase.execute(paginationDto)).rejects.toThrow(errorMessage); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/getAll.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type PaginationDto, type PaginationResponseEntity } from '../../../shared'; 2 | import { type TodoEntity } from '../entities'; 3 | import { type TodoRepository } from '../repositories/respository'; 4 | 5 | export interface GetTodosUseCase { 6 | execute: (pagination: PaginationDto) => Promise>; 7 | } 8 | 9 | export class GetTodos implements GetTodosUseCase { 10 | constructor(private readonly repository: TodoRepository) {} 11 | 12 | async execute(pagination: PaginationDto): Promise> { 13 | return await this.repository.getAll(pagination); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/getById.usecase.test.ts: -------------------------------------------------------------------------------- 1 | import { GetTodoByIdDto } from '../dtos'; 2 | import { TodoEntity } from '../entities'; 3 | import { TodoRepository } from '../repositories/respository'; 4 | import { GetTodoById, GetTodoByIdUseCase } from './getById.usecase'; 5 | 6 | describe('tests in getById.usecase.ts', () => { 7 | let repository: jest.Mocked; 8 | let getTodoByIdUseCase: GetTodoByIdUseCase; 9 | 10 | beforeEach(() => { 11 | repository = { 12 | create: jest.fn(), 13 | getAll: jest.fn(), 14 | getById: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn() 17 | } as jest.Mocked; 18 | 19 | getTodoByIdUseCase = new GetTodoById(repository); 20 | }); 21 | 22 | test('should get a todo item by id successfully', async () => { 23 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 24 | const todoEntity = new TodoEntity(getByIdDto.id, 'Test Todo'); 25 | 26 | repository.getById.mockResolvedValue(todoEntity); 27 | 28 | const result = await getTodoByIdUseCase.execute(getByIdDto); 29 | 30 | expect(repository.getById).toHaveBeenCalledWith(getByIdDto); 31 | expect(result).toEqual(todoEntity); 32 | }); 33 | 34 | test('should throw an error if repository.getById fails', async () => { 35 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 36 | const error = new Error('Repository getById failed'); 37 | 38 | repository.getById.mockRejectedValue(error); 39 | 40 | await expect(getTodoByIdUseCase.execute(getByIdDto)).rejects.toThrow(error); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/getById.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type GetTodoByIdDto } from '../dtos'; 2 | import { type TodoEntity } from '../entities'; 3 | import { type TodoRepository } from '../repositories/respository'; 4 | 5 | export interface GetTodoByIdUseCase { 6 | execute: (getByIdDto: GetTodoByIdDto) => Promise; 7 | } 8 | 9 | export class GetTodoById implements GetTodoByIdUseCase { 10 | constructor(private readonly repository: TodoRepository) {} 11 | 12 | async execute(getByIdDto: GetTodoByIdDto): Promise { 13 | return await this.repository.getById(getByIdDto); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create.usecase'; 2 | export * from './delete.usecase'; 3 | export * from './update.usecase'; 4 | export * from './getAll.usecase'; 5 | export * from './getById.usecase'; 6 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/update.usecase.test.ts: -------------------------------------------------------------------------------- 1 | import { UpdateTodoDto } from '../dtos'; 2 | import { TodoEntity } from '../entities'; 3 | import { TodoRepository } from '../repositories/respository'; 4 | import { UpdateTodo, UpdateTodoUseCase } from './update.usecase'; 5 | 6 | describe('tests in update.usecase.ts', () => { 7 | let repository: jest.Mocked; 8 | let updateTodoUseCase: UpdateTodoUseCase; 9 | 10 | beforeEach(() => { 11 | repository = { 12 | create: jest.fn(), 13 | getAll: jest.fn(), 14 | getById: jest.fn(), 15 | update: jest.fn(), 16 | delete: jest.fn() 17 | } as jest.Mocked; 18 | 19 | updateTodoUseCase = new UpdateTodo(repository); 20 | }); 21 | 22 | test('should update a todo item successfully', async () => { 23 | const updateData = UpdateTodoDto.create({ id: 1, text: 'Test Todo updated' }); 24 | const updatedTodo: TodoEntity = { id: 1, text: 'Test Todo updated', isCompleted: false }; 25 | 26 | repository.update.mockResolvedValue(updatedTodo); 27 | 28 | const result = await updateTodoUseCase.execute(updateData); 29 | 30 | expect(repository.update).toHaveBeenCalledWith(updateData); 31 | expect(result).toEqual(updatedTodo); 32 | }); 33 | 34 | test('should throw an error if repository.update fails', async () => { 35 | const updateData = UpdateTodoDto.create({ id: 1, text: 'Test Todo updated' }); 36 | const error = new Error('Repository update failed'); 37 | 38 | repository.update.mockRejectedValue(error); 39 | 40 | await expect(updateTodoUseCase.execute(updateData)).rejects.toThrow(error); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/features/todos/domain/usecases/update.usecase.ts: -------------------------------------------------------------------------------- 1 | import { type UpdateTodoDto } from '../dtos'; 2 | import { type TodoEntity } from '../entities'; 3 | import { type TodoRepository } from '../repositories/respository'; 4 | 5 | export interface UpdateTodoUseCase { 6 | execute: (data: UpdateTodoDto) => Promise; 7 | } 8 | 9 | export class UpdateTodo implements UpdateTodoUseCase { 10 | constructor(private readonly repository: TodoRepository) {} 11 | 12 | async execute(data: UpdateTodoDto): Promise { 13 | return await this.repository.update(data); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/todos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './domain'; 2 | export * from './infraestructure'; 3 | export * from './presentation/controller'; 4 | export * from './presentation/routes'; 5 | -------------------------------------------------------------------------------- /src/features/todos/infraestructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './local.datasource.impl'; 2 | export * from './repository.impl'; 3 | -------------------------------------------------------------------------------- /src/features/todos/infraestructure/local.datasource.impl.test.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '../../../core'; 2 | import { PaginationDto } from '../../shared'; 3 | import { CreateTodoDto, GetTodoByIdDto, TodoEntity, UpdateTodoDto } from '../domain'; 4 | import { TodoDatasourceImpl } from './local.datasource.impl'; 5 | 6 | describe('tests in local.datasource.impl.ts', () => { 7 | const todoDatasource = new TodoDatasourceImpl(); 8 | 9 | test('should return a TODOs list', async () => { 10 | const paginationDto = PaginationDto.create({ page: 1, limit: 10 }); 11 | const expectedResults = [ 12 | { id: 1, text: 'First TODO...', isCompleted: false }, 13 | { id: 2, text: 'Second TODO...', isCompleted: false } 14 | ]; 15 | const result = await todoDatasource.getAll(paginationDto); 16 | expect(result).toEqual({ 17 | results: expectedResults.map((todo) => TodoEntity.fromJson(todo)), 18 | currentPage: 1, 19 | nextPage: null, 20 | prevPage: null, 21 | total: 2, 22 | totalPages: 1 23 | }); 24 | }); 25 | 26 | test('should return a TODO by id', async () => { 27 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 28 | const todo = await todoDatasource.getById(getByIdDto); 29 | expect(todo).toEqual(TodoEntity.fromJson({ id: 1, text: 'First TODO...', isCompleted: false })); 30 | }); 31 | 32 | test('should return an error when trying to get a non-existing TODO', async () => { 33 | try { 34 | const getByIdDto = GetTodoByIdDto.create({ id: 100 }); 35 | await todoDatasource.getById(getByIdDto); 36 | } catch (error) { 37 | if (error instanceof AppError) { 38 | expect(error).toEqual(AppError.notFound('Todo with id 100 not found')); 39 | } 40 | } 41 | }); 42 | 43 | test('should create a TODO', async () => { 44 | const data = CreateTodoDto.create({ text: 'Test Todo' }); 45 | const createdTodo: TodoEntity = { id: expect.any(Number), text: 'Test Todo', isCompleted: false }; 46 | 47 | const result = await todoDatasource.create(data); 48 | expect(result).toEqual(createdTodo); 49 | }); 50 | 51 | test('should update a TODO', async () => { 52 | const data = UpdateTodoDto.create({ id: 1, text: 'Test Todo' }); 53 | const updatedTodo: TodoEntity = { id: 1, text: 'Test Todo', isCompleted: false }; 54 | 55 | const result = await todoDatasource.update(data); 56 | expect(result).toEqual(updatedTodo); 57 | }); 58 | 59 | test('should delete a TODO', async () => { 60 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 61 | const todo = await todoDatasource.getById(getByIdDto); 62 | const deletedTodo = await todoDatasource.delete(getByIdDto); 63 | expect(deletedTodo).toEqual(todo); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/features/todos/infraestructure/local.datasource.impl.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\infraestructure\local.datasource.impl.ts 2 | 3 | import { ONE, ZERO, AppError } from '../../../core'; 4 | import { type PaginationDto, type PaginationResponseEntity } from '../../shared'; 5 | import { 6 | TodoEntity, 7 | type CreateTodoDto, 8 | type GetTodoByIdDto, 9 | type UpdateTodoDto, 10 | type TodoDatasource 11 | } from '../domain'; 12 | 13 | const TODOS_MOCK = [ 14 | { 15 | id: 1, 16 | text: 'First TODO...', 17 | isCompleted: false 18 | }, 19 | { 20 | id: 2, 21 | text: 'Second TODO...', 22 | isCompleted: false 23 | } 24 | ]; 25 | 26 | export class TodoDatasourceImpl implements TodoDatasource { 27 | public async getAll(pagination: PaginationDto): Promise> { 28 | const { page, limit } = pagination; 29 | 30 | const todos = TODOS_MOCK; 31 | const total = TODOS_MOCK.length; 32 | 33 | const totalPages = Math.ceil(total / limit); 34 | const nextPage = page < totalPages ? page + ONE : null; 35 | const prevPage = page > ONE ? page - ONE : null; 36 | 37 | return { 38 | results: todos.slice((page - ONE) * limit, page * limit).map((todo) => TodoEntity.fromJson(todo)), 39 | currentPage: page, 40 | nextPage, 41 | prevPage, 42 | total, 43 | totalPages 44 | }; 45 | } 46 | 47 | public async getById(getByIdDto: GetTodoByIdDto): Promise { 48 | const todo = TODOS_MOCK.find((todo) => todo.id === getByIdDto.id); 49 | if (!todo) throw AppError.notFound(`Todo with id ${getByIdDto.id} not found`); 50 | return TodoEntity.fromJson(todo); 51 | } 52 | 53 | public async create(createDto: CreateTodoDto): Promise { 54 | const createdTodo = { id: TODOS_MOCK.length + ONE, ...createDto, isCompleted: false }; 55 | TODOS_MOCK.push(createdTodo); 56 | return TodoEntity.fromJson(createdTodo); 57 | } 58 | 59 | public async update(updateDto: UpdateTodoDto): Promise { 60 | const { id } = await this.getById(updateDto); 61 | const index = TODOS_MOCK.findIndex((todo) => todo.id === id); 62 | 63 | TODOS_MOCK[index] = { 64 | ...TODOS_MOCK[index], 65 | ...Object.fromEntries(Object.entries(updateDto).filter(([_, v]) => v !== undefined)) 66 | }; 67 | 68 | return TodoEntity.fromJson(TODOS_MOCK[index]); 69 | } 70 | 71 | public async delete(getByIdDto: GetTodoByIdDto): Promise { 72 | const { id } = await this.getById(getByIdDto); 73 | const index = TODOS_MOCK.findIndex((todo) => todo.id === id); 74 | const deletedTodo = TODOS_MOCK.splice(index, ONE)[ZERO]; 75 | return TodoEntity.fromJson(deletedTodo); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/features/todos/infraestructure/repository.impl.test.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from '../../shared'; 2 | import { CreateTodoDto, GetTodoByIdDto, UpdateTodoDto } from '../domain'; 3 | import { TodoDatasource } from '../domain/datasources/datasource'; 4 | import { TodoRepositoryImpl } from './repository.impl'; 5 | 6 | describe('tests in repository.impl.ts', () => { 7 | const datasource = { 8 | create: jest.fn(), 9 | getAll: jest.fn(), 10 | getById: jest.fn(), 11 | update: jest.fn(), 12 | delete: jest.fn() 13 | } as TodoDatasource; 14 | 15 | const repository = new TodoRepositoryImpl(datasource); 16 | 17 | test('create should call datasource.create with right arguments', async () => { 18 | const createDto = CreateTodoDto.create({ text: 'Test Todo' }); 19 | await repository.create(createDto); 20 | expect(datasource.create).toHaveBeenCalledWith(createDto); 21 | }); 22 | 23 | test('getAll should call datasource.getAll with right arguments', async () => { 24 | const paginationDto = PaginationDto.create({ page: 1, limit: 10 }); 25 | await repository.getAll(paginationDto); 26 | expect(datasource.getAll).toHaveBeenCalledWith(paginationDto); 27 | }); 28 | 29 | test('getById should call datasource.getById with right arguments', async () => { 30 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 31 | await repository.getById(getByIdDto); 32 | expect(datasource.getById).toHaveBeenCalledWith(getByIdDto); 33 | }); 34 | 35 | test('update should call datasource.update with right arguments', async () => { 36 | const updateDto = UpdateTodoDto.create({ id: 1, text: 'Test Todo updated' }); 37 | await repository.update(updateDto); 38 | expect(datasource.update).toHaveBeenCalledWith(updateDto); 39 | }); 40 | 41 | test('delete should call datasource.delete with right arguments', async () => { 42 | const getByIdDto = GetTodoByIdDto.create({ id: 1 }); 43 | await repository.delete(getByIdDto); 44 | expect(datasource.delete).toHaveBeenCalledWith(getByIdDto); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/features/todos/infraestructure/repository.impl.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\infraestructure\repository.impl.ts 2 | 3 | import { type PaginationDto, type PaginationResponseEntity } from '../../shared'; 4 | 5 | import { 6 | type TodoEntity, 7 | type TodoDatasource, 8 | type GetTodoByIdDto, 9 | type UpdateTodoDto, 10 | type CreateTodoDto, 11 | type TodoRepository 12 | } from '../domain'; 13 | 14 | export class TodoRepositoryImpl implements TodoRepository { 15 | constructor(private readonly datasource: TodoDatasource) {} 16 | 17 | async create(createDto: CreateTodoDto): Promise { 18 | return await this.datasource.create(createDto); 19 | } 20 | 21 | async getAll(pagination: PaginationDto): Promise> { 22 | return await this.datasource.getAll(pagination); 23 | } 24 | 25 | async getById(getByIdDto: GetTodoByIdDto): Promise { 26 | return await this.datasource.getById(getByIdDto); 27 | } 28 | 29 | async update(updateDto: UpdateTodoDto): Promise { 30 | return await this.datasource.update(updateDto); 31 | } 32 | 33 | async delete(getByIdDto: GetTodoByIdDto): Promise { 34 | return await this.datasource.delete(getByIdDto); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/features/todos/presentation/controller.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\presentation\controller.ts 2 | 3 | import { type NextFunction, type Request, type Response } from 'express'; 4 | 5 | import { type SuccessResponse, HttpCode, ONE, TEN } from '../../../core'; 6 | import { PaginationDto, type PaginationResponseEntity } from '../../shared'; 7 | 8 | import { 9 | CreateTodo, 10 | DeleteTodo, 11 | GetTodoById, 12 | UpdateTodo, 13 | CreateTodoDto, 14 | GetTodoByIdDto, 15 | UpdateTodoDto, 16 | GetTodos, 17 | type TodoEntity, 18 | type TodoRepository 19 | } from '../domain'; 20 | 21 | interface Params { 22 | id: string; 23 | } 24 | 25 | interface RequestBody { 26 | text: string; 27 | isCompleted: string; 28 | } 29 | 30 | interface RequestQuery { 31 | page: string; 32 | limit: string; 33 | } 34 | 35 | export class TodoController { 36 | //* Dependency injection 37 | constructor(private readonly repository: TodoRepository) {} 38 | 39 | public getAll = ( 40 | req: Request, 41 | res: Response>>, 42 | next: NextFunction 43 | ): void => { 44 | const { page = ONE, limit = TEN } = req.query; 45 | const paginationDto = PaginationDto.create({ page: +page, limit: +limit }); 46 | new GetTodos(this.repository) 47 | .execute(paginationDto) 48 | .then((result) => res.json({ data: result })) 49 | .catch((error) => { 50 | next(error); 51 | }); 52 | }; 53 | 54 | public getById = (req: Request, res: Response>, next: NextFunction): void => { 55 | const { id } = req.params; 56 | const getTodoByIdDto = GetTodoByIdDto.create({ id: Number(id) }); 57 | new GetTodoById(this.repository) 58 | .execute(getTodoByIdDto) 59 | .then((result) => res.json({ data: result })) 60 | .catch(next); 61 | }; 62 | 63 | public create = ( 64 | req: Request, 65 | res: Response>, 66 | next: NextFunction 67 | ): void => { 68 | const { text } = req.body; 69 | const createDto = CreateTodoDto.create({ text }); 70 | new CreateTodo(this.repository) 71 | .execute(createDto) 72 | .then((result) => res.status(HttpCode.CREATED).json({ data: result })) 73 | .catch(next); 74 | }; 75 | 76 | public update = ( 77 | req: Request, 78 | res: Response>, 79 | next: NextFunction 80 | ): void => { 81 | const { id } = req.params; 82 | const { text, isCompleted } = req.body; 83 | const updateDto = UpdateTodoDto.create({ id: Number(id), text, isCompleted }); 84 | new UpdateTodo(this.repository) 85 | .execute(updateDto) 86 | .then((result) => res.json({ data: result })) 87 | .catch(next); 88 | }; 89 | 90 | public delete = (req: Request, res: Response>, next: NextFunction): void => { 91 | const { id } = req.params; 92 | const getTodoByIdDto = GetTodoByIdDto.create({ id: Number(id) }); 93 | new DeleteTodo(this.repository) 94 | .execute(getTodoByIdDto) 95 | .then((result) => res.json({ data: result })) 96 | .catch(next); 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /src/features/todos/presentation/routes.test.ts: -------------------------------------------------------------------------------- 1 | // use supertest to test routes 2 | import request from 'supertest'; 3 | import { Application } from 'express'; 4 | 5 | import { testServer } from '../../../testServer'; 6 | import { ErrorResponse, HttpCode, SuccessResponse, envs } from '../../../core'; 7 | import { PaginationResponseEntity } from '../../shared'; 8 | import { TodoEntity } from '../domain'; 9 | 10 | /** 11 | * Registers a test user and returns their authentication token 12 | * @param app Express application instance 13 | * @param userData Optional custom user data 14 | * @returns Authentication token 15 | */ 16 | export async function registerTestUser(app: Application) { 17 | const defaultUserData = { 18 | name: 'Test User', 19 | email: `test@example.com`, 20 | password: 'Password123!' 21 | }; 22 | 23 | // Register the user 24 | const registerResponse = await request(app) 25 | .post(`${envs.API_PREFIX}/auth/register`) 26 | .send(defaultUserData) 27 | .expect(HttpCode.CREATED); 28 | 29 | const { data } = registerResponse.body; 30 | const { token } = data; 31 | 32 | return token; 33 | } 34 | 35 | describe('tests in routes', () => { 36 | const url = `${envs.API_PREFIX}/todos`; 37 | let authToken: string; 38 | 39 | beforeAll(async () => { 40 | await testServer.start(); 41 | const token = await registerTestUser(testServer.app); 42 | authToken = token; 43 | }); 44 | 45 | afterAll(() => { 46 | testServer.close(); 47 | }); 48 | 49 | test('should return TODOs /todos', async () => { 50 | const expectedResponse = { 51 | data: { 52 | currentPage: 1, 53 | nextPage: null, 54 | prevPage: null, 55 | results: [ 56 | { id: 1, isCompleted: false, text: 'First TODO...' }, 57 | { id: 2, isCompleted: false, text: 'Second TODO...' } 58 | ], 59 | total: 2, 60 | totalPages: 1 61 | } 62 | }; 63 | 64 | await request(testServer.app) 65 | .get(url) 66 | .expect(HttpCode.OK) 67 | .expect('Content-Type', /json/) 68 | .then(({ body }: { body: SuccessResponse> }) => { 69 | expect(body).toEqual(expectedResponse); 70 | expect(body.data?.results.length).toBe(2); 71 | }); 72 | }); 73 | 74 | test('should return TODOs /todos/1', async () => { 75 | const expectedResponse = { 76 | data: { 77 | id: 1, 78 | isCompleted: false, 79 | text: 'First TODO...' 80 | } 81 | }; 82 | 83 | await request(testServer.app) 84 | .get(`${url}/${expectedResponse.data.id}`) 85 | .expect(HttpCode.OK) 86 | .expect('Content-Type', /json/) 87 | .then(({ body }: { body: SuccessResponse }) => { 88 | expect(body).toEqual(expectedResponse); 89 | }); 90 | }); 91 | 92 | test('should return 404 NOT_FOUND when TODOs /todos/100', async () => { 93 | await request(testServer.app) 94 | .get(`${url}/100`) 95 | .expect(HttpCode.NOT_FOUND) 96 | .expect('Content-Type', /json/) 97 | .then(({ body }: { body: ErrorResponse }) => { 98 | expect(body.message).toEqual('Todo with id 100 not found'); 99 | }); 100 | }); 101 | 102 | test('should return 400 BAD_REQUEST when TODOs /todos/abc', async () => { 103 | await request(testServer.app) 104 | .get(`${url}/abc`) 105 | .expect(HttpCode.BAD_REQUEST) 106 | .expect('Content-Type', /json/) 107 | .then(({ body }: { body: ErrorResponse }) => { 108 | expect(body.message).toEqual('Error validating get todo by id'); 109 | expect(body.validationErrors).toEqual([{ fields: ['id'], constraint: 'Id is not a valid number' }]); 110 | }); 111 | }); 112 | 113 | test('should return a new TODO /todos', async () => { 114 | const expectedResponse = { 115 | data: { 116 | id: 3, 117 | isCompleted: false, 118 | text: 'New TODO...' 119 | } 120 | }; 121 | 122 | await request(testServer.app) 123 | .post(url) 124 | .set('Authorization', `Bearer ${authToken}`) 125 | .send(expectedResponse.data) 126 | .expect(HttpCode.CREATED) 127 | .expect('Content-Type', /json/) 128 | .then(({ body }: { body: SuccessResponse }) => { 129 | expect(body).toEqual(expectedResponse); 130 | }); 131 | }); 132 | 133 | test('should return 400 BAD_REQUEST when TODO /todos with undefined text', async () => { 134 | const data = { id: 3, isCompleted: false, text: undefined }; 135 | 136 | await request(testServer.app) 137 | .post(url) 138 | .set('Authorization', `Bearer ${authToken}`) 139 | .send(data) 140 | .expect(HttpCode.BAD_REQUEST) 141 | .expect('Content-Type', /json/) 142 | .then(({ body }: { body: ErrorResponse }) => { 143 | expect(body.message).toEqual('Error validating create todo'); 144 | expect(body.validationErrors).toEqual([{ fields: ['text'], constraint: 'Text is required' }]); 145 | }); 146 | }); 147 | 148 | test('should return 400 BAD_REQUEST when TODO /todos with empty text', async () => { 149 | const data = { id: 3, isCompleted: false, text: '' }; 150 | 151 | await request(testServer.app) 152 | .post(url) 153 | .set('Authorization', `Bearer ${authToken}`) 154 | .send(data) 155 | .expect(HttpCode.BAD_REQUEST) 156 | .expect('Content-Type', /json/) 157 | .then(({ body }: { body: ErrorResponse }) => { 158 | expect(body.message).toEqual('Error validating create todo'); 159 | expect(body.validationErrors).toEqual([{ fields: ['text'], constraint: 'Text is required' }]); 160 | }); 161 | }); 162 | 163 | test('should update a TODO /todos/1', async () => { 164 | const expectedResponse = { 165 | data: { 166 | id: 1, 167 | isCompleted: true, 168 | text: 'First TODO...' 169 | } 170 | }; 171 | 172 | await request(testServer.app) 173 | .put(`${url}/${expectedResponse.data.id}`) 174 | .send(expectedResponse.data) 175 | .expect(HttpCode.OK) 176 | .expect('Content-Type', /json/) 177 | .then(({ body }: { body: SuccessResponse }) => { 178 | expect(body).toEqual(expectedResponse); 179 | }); 180 | }); 181 | 182 | test('should delete a TODO /todos/1', async () => { 183 | const expectedResponse = { 184 | data: { 185 | id: 2, 186 | isCompleted: false, 187 | text: 'Second TODO...' 188 | } 189 | }; 190 | 191 | await request(testServer.app) 192 | .delete(`${url}/${expectedResponse.data.id}`) 193 | .expect(HttpCode.OK) 194 | .expect('Content-Type', /json/) 195 | .then(({ body }: { body: SuccessResponse }) => { 196 | expect(body).toEqual(expectedResponse); 197 | }); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/features/todos/presentation/routes.ts: -------------------------------------------------------------------------------- 1 | // src\features\todos\presentation\routes.ts 2 | 3 | import { Router } from 'express'; 4 | 5 | import { TodoDatasourceImpl, TodoRepositoryImpl } from '../infraestructure'; 6 | import { TodoController } from './controller'; 7 | import { AuthDatasourceImpl, AuthMiddleware, AuthRepositoryImpl } from '../../auth'; 8 | 9 | export class TodoRoutes { 10 | static get routes(): Router { 11 | const router = Router(); 12 | 13 | //* This datasource can be change 14 | const datasource = new TodoDatasourceImpl(); 15 | const repository = new TodoRepositoryImpl(datasource); 16 | const controller = new TodoController(repository); 17 | 18 | // * Authentication middleware 19 | const authDatasource = new AuthDatasourceImpl(); 20 | const authRepository = new AuthRepositoryImpl(authDatasource); 21 | const authMiddleware = new AuthMiddleware(authRepository); 22 | 23 | router.get('/', controller.getAll); 24 | router.get('/:id', controller.getById); 25 | router.post('/', [authMiddleware.validateJWT], controller.create); 26 | router.put('/:id', controller.update); 27 | router.delete('/:id', controller.delete); 28 | 29 | // rest of operations 30 | // ... 31 | 32 | return router; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | // src\routes.ts 2 | 3 | import { Router } from 'express'; 4 | 5 | import { TodoRoutes } from './features/todos'; 6 | import { AuthRoutes } from './features/auth'; 7 | 8 | export class AppRoutes { 9 | static get routes(): Router { 10 | const router = Router(); 11 | 12 | router.use('/auth', AuthRoutes.routes); 13 | router.use('/todos', TodoRoutes.routes); 14 | 15 | // rest of routes 16 | // ... 17 | 18 | return router; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | // src\server.ts 2 | 3 | import { type Server as ServerHttp, type IncomingMessage, type ServerResponse } from 'http'; 4 | import express, { type Router, type Request, type Response, type NextFunction } from 'express'; 5 | import compression from 'compression'; 6 | import rateLimit from 'express-rate-limit'; 7 | 8 | import { HttpCode, ONE_HUNDRED, ONE_THOUSAND, SIXTY, AppError } from './core'; 9 | import { CustomMiddlewares, ErrorMiddleware } from './features/shared'; 10 | 11 | interface ServerOptions { 12 | port: number; 13 | routes: Router; 14 | apiPrefix: string; 15 | } 16 | 17 | export class Server { 18 | public readonly app = express(); // This is public for testing purposes 19 | private serverListener?: ServerHttp; 20 | private readonly port: number; 21 | private readonly routes: Router; 22 | private readonly apiPrefix: string; 23 | 24 | constructor(options: ServerOptions) { 25 | const { port, routes, apiPrefix } = options; 26 | this.port = port; 27 | this.routes = routes; 28 | this.apiPrefix = apiPrefix; 29 | } 30 | 31 | async start(): Promise { 32 | //* Middlewares 33 | this.app.use(express.json()); // parse json in request body (allow raw) 34 | this.app.use(express.urlencoded({ extended: true })); // allow x-www-form-urlencoded 35 | this.app.use(compression()); 36 | // limit repeated requests to public APIs 37 | this.app.use( 38 | rateLimit({ 39 | max: ONE_HUNDRED, 40 | windowMs: SIXTY * SIXTY * ONE_THOUSAND, 41 | message: 'Too many requests from this IP, please try again in one hour' 42 | }) 43 | ); 44 | 45 | // Shared Middlewares 46 | this.app.use(CustomMiddlewares.writeInConsole); 47 | 48 | // CORS 49 | this.app.use((req, res, next) => { 50 | // Add your origins 51 | const allowedOrigins = ['http://localhost:3000']; 52 | const origin = req.headers.origin; 53 | // TODO: Fix this 54 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 55 | if (allowedOrigins.includes(origin!)) { 56 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 57 | res.setHeader('Access-Control-Allow-Origin', origin!); 58 | } 59 | // Do not forget to add all the necessary methods and headers to avoid CORS problems 60 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); 61 | // For example, if you are going to use authorization headers do not forget to add it here 62 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); 63 | next(); 64 | }); 65 | 66 | //* Routes 67 | this.app.use(this.apiPrefix, this.routes); 68 | 69 | // Test rest api 70 | this.app.get('/', (_req: Request, res: Response) => { 71 | res.status(HttpCode.OK).send({ 72 | message: `Welcome to Initial API! \n Endpoints available at http://localhost:${this.port}${this.apiPrefix}` 73 | }); 74 | }); 75 | 76 | //* Handle not found routes in /api/v1/* (only if 'Public content folder' is not available) 77 | this.routes.all('*', (req: Request, _: Response, next: NextFunction): void => { 78 | next(AppError.notFound(`Cant find ${req.originalUrl} on this server!`)); 79 | }); 80 | 81 | // Handle errors middleware 82 | this.routes.use(ErrorMiddleware.handleError); 83 | 84 | this.serverListener = this.app.listen(this.port, () => { 85 | console.log(`Server running on port ${this.port}...`); 86 | }); 87 | } 88 | 89 | close(): void { 90 | this.serverListener?.close(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/testServer.ts: -------------------------------------------------------------------------------- 1 | // src/testServer.ts 2 | 3 | import { envs } from './core'; 4 | import { AppRoutes } from './routes'; 5 | import { Server } from './server'; 6 | 7 | // This is a test server for testing purposes 8 | export const testServer = new Server({ 9 | port: envs.PORT, 10 | apiPrefix: envs.API_PREFIX, 11 | routes: AppRoutes.routes 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"], 3 | "include": ["src/**/*"], 4 | "compilerOptions": { 5 | "types": ["node", "jest", "express"], 6 | "target": "ESNext", 7 | "module": "CommonJS", 8 | "rootDir": "./src", 9 | "moduleResolution": "Node", 10 | "typeRoots": ["./node_modules/@types"], 11 | "sourceMap": true, 12 | "outDir": "dist/", 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "strictNullChecks": true, 17 | "skipLibCheck": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------