From 099217a4dbf2a6e67b05ce08c300b8490acb4935 Mon Sep 17 00:00:00 2001 From: YAustinXYZ Date: Fri, 19 Jun 2026 13:14:37 -0600 Subject: [PATCH] Implementa S02: config, main bootstrap y manejo de errores --- package-lock.json | 524 +++++++++++++++++--- package.json | 20 +- src/app.module.ts | 16 +- src/common/filters/http-exception.filter.ts | 124 +++++ src/config/configuration.ts | 38 ++ src/config/env.validation.ts | 30 ++ src/main.ts | 64 ++- test/app.e2e-spec.ts | 94 +++- test/env.setup.ts | 42 ++ test/env.validation.spec.ts | 111 +++++ test/jest-e2e.json | 3 +- tmp-lines.txt | Bin 0 -> 7866 bytes 12 files changed, 980 insertions(+), 86 deletions(-) create mode 100644 src/common/filters/http-exception.filter.ts create mode 100644 src/config/configuration.ts create mode 100644 src/config/env.validation.ts create mode 100644 test/env.setup.ts create mode 100644 test/env.validation.spec.ts create mode 100644 tmp-lines.txt diff --git a/package-lock.json b/package-lock.json index 17176e2..d1d19e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,19 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.4", + "@prisma/client": "^7.8.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "compression": "^1.8.1", + "helmet": "^8.2.0", + "joi": "^18.2.3", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -21,6 +30,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -216,6 +226,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -746,33 +757,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -937,6 +925,54 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2056,6 +2092,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -2126,6 +2168,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2272,6 +2315,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2298,11 +2342,27 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "11.1.27", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", + "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -2337,11 +2397,32 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.27", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2462,6 +2543,51 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz", + "integrity": "sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "4.1.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.6" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@nestjs/testing": { "version": "11.1.27", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz", @@ -2537,6 +2663,43 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -2564,6 +2727,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -2682,6 +2851,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2818,6 +2998,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2888,6 +3069,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2950,6 +3137,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -3300,9 +3488,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3317,9 +3502,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3334,9 +3516,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3351,9 +3530,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3368,9 +3544,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3385,9 +3558,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3402,9 +3572,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3419,9 +3586,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3436,9 +3600,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3453,9 +3614,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3495,6 +3653,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -3731,6 +3923,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3780,6 +3973,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3949,7 +4143,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -4206,6 +4399,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4404,6 +4598,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4447,6 +4642,25 @@ "dev": true, "license": "MIT" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -4627,6 +4841,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4881,6 +5149,45 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5056,6 +5363,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5116,6 +5424,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5348,6 +5657,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6013,6 +6323,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6381,6 +6703,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -7120,6 +7443,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7258,6 +7599,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "license": "MIT" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7318,7 +7665,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -7780,6 +8126,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8139,6 +8494,7 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8318,7 +8674,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -8415,6 +8772,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8903,6 +9261,30 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", + "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -9029,6 +9411,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9349,6 +9732,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9511,8 +9895,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9716,6 +10101,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -9850,7 +10244,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -9869,7 +10262,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -9883,7 +10275,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -9898,7 +10289,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -9908,8 +10298,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -9917,7 +10306,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index 999c713..ecb6af0 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,19 @@ }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.4", + "@prisma/client": "^7.8.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "compression": "^1.8.1", + "helmet": "^8.2.0", + "joi": "^18.2.3", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -32,6 +41,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -57,7 +67,7 @@ "json", "ts" ], - "rootDir": "src", + "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" @@ -65,7 +75,11 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], - "coverageDirectory": "../coverage", + "coverageDirectory": "coverage", + "modulePathIgnorePatterns": [ + "/.agents", + "/.claude" + ], "testEnvironment": "node" } } diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..641aac1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,23 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { envValidationSchema } from './config/env.validation'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ - imports: [], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + validationSchema: envValidationSchema, + validationOptions: { + abortEarly: false, + allowUnknown: false, + }, + load: [configuration], + }), + ], controllers: [AppController], providers: [AppService], }) diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..49c85a0 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,124 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +interface PrismaKnownRequestError { + name: string; + code: string; +} + +function isPrismaKnownRequestError( + value: unknown, +): value is PrismaKnownRequestError { + return ( + typeof value === 'object' && + value !== null && + (value as Record).name === + 'PrismaClientKnownRequestError' && + typeof (value as Record).code === 'string' + ); +} + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const path = request.url; + const method = request.method; + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message: string | string[] = 'Internal server error'; + let code: string | undefined; + let errors: string[] | Record = {}; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const responseBody = exception.getResponse(); + + if (typeof responseBody === 'string') { + message = responseBody; + } else if ( + typeof responseBody === 'object' && + responseBody !== null && + 'message' in responseBody + ) { + const body = responseBody as Record; + const rawMessage = body.message; + + if (Array.isArray(rawMessage)) { + message = rawMessage.map((item) => String(item)); + errors = rawMessage.map((item) => String(item)); + } else if (typeof rawMessage === 'string') { + message = rawMessage; + } else { + message = String(rawMessage); + } + + if (typeof body.error === 'string') { + code = body.error.toUpperCase().replace(/\s+/g, '_'); + } + } + + if (!code) { + switch (status) { + case HttpStatus.BAD_REQUEST: + code = 'BAD_REQUEST'; + break; + case HttpStatus.NOT_FOUND: + code = 'NOT_FOUND'; + break; + case HttpStatus.CONFLICT: + code = 'CONFLICT'; + break; + case HttpStatus.UNAUTHORIZED: + code = 'UNAUTHORIZED'; + break; + case HttpStatus.FORBIDDEN: + code = 'FORBIDDEN'; + break; + } + } + } else if (isPrismaKnownRequestError(exception)) { + switch (exception.code) { + case 'P2002': + status = HttpStatus.CONFLICT; + message = 'A record with this value already exists'; + code = 'DUPLICATE_ENTRY'; + break; + case 'P2025': + status = HttpStatus.NOT_FOUND; + message = 'Record not found'; + code = 'NOT_FOUND'; + break; + default: + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = 'Database error'; + code = 'DB_ERROR'; + } + } + + const logMessage = `${method} ${path} ${status} — ${ + Array.isArray(message) ? message.join(', ') : message + }`; + this.logger.error(logMessage); + + response.status(status).json({ + statusCode: status, + message, + code, + errors, + timestamp: new Date().toISOString(), + path, + }); + } +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts new file mode 100644 index 0000000..0a491cb --- /dev/null +++ b/src/config/configuration.ts @@ -0,0 +1,38 @@ +export default () => ({ + app: { + nodeEnv: process.env.NODE_ENV, + port: Number(process.env.PORT), + apiPrefix: process.env.API_PREFIX, + corsOrigins: process.env.CORS_ORIGINS, + }, + database: { + url: process.env.DATABASE_URL, + directUrl: process.env.DIRECT_URL, + }, + supabase: { + url: process.env.SUPABASE_URL, + jwtSecret: process.env.SUPABASE_JWT_SECRET, + serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, + }, + stellar: { + network: process.env.STELLAR_NETWORK, + horizonUrl: process.env.STELLAR_HORIZON_URL, + rpcUrl: process.env.STELLAR_RPC_URL, + usdcIssuer: process.env.STELLAR_USDC_ISSUER, + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE, + }, + webauthn: { + rpId: process.env.WEBAUTHN_RP_ID, + rpName: process.env.WEBAUTHN_RP_NAME, + origin: process.env.WEBAUTHN_ORIGIN, + }, + payments: { + submitTimeoutMs: Number(process.env.PAYMENT_SUBMIT_TIMEOUT_MS), + pollIntervalMs: Number(process.env.PAYMENT_POLL_INTERVAL_MS), + pollMaxAttempts: Number(process.env.PAYMENT_POLL_MAX_ATTEMPTS), + }, + throttle: { + ttlMs: Number(process.env.THROTTLE_TTL_MS), + limit: Number(process.env.THROTTLE_LIMIT), + }, +}); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..6985a9f --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,30 @@ +import Joi from 'joi'; + +export const envValidationSchema = Joi.object({ + NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), + PORT: Joi.number().integer().positive().required(), + API_PREFIX: Joi.string().required(), + CORS_ORIGINS: Joi.string().required(), + DATABASE_URL: Joi.string() + .uri({ scheme: ['postgresql', 'postgres'] }) + .required(), + DIRECT_URL: Joi.string() + .uri({ scheme: ['postgresql', 'postgres'] }) + .required(), + SUPABASE_URL: Joi.string().uri().required(), + SUPABASE_JWT_SECRET: Joi.string().required(), + SUPABASE_SERVICE_ROLE_KEY: Joi.string().required(), + STELLAR_NETWORK: Joi.string().valid('testnet', 'mainnet').required(), + STELLAR_HORIZON_URL: Joi.string().uri().required(), + STELLAR_RPC_URL: Joi.string().uri().required(), + STELLAR_USDC_ISSUER: Joi.string().required(), + STELLAR_NETWORK_PASSPHRASE: Joi.string().required(), + WEBAUTHN_RP_ID: Joi.string().required(), + WEBAUTHN_RP_NAME: Joi.string().required(), + WEBAUTHN_ORIGIN: Joi.string().uri().required(), + PAYMENT_SUBMIT_TIMEOUT_MS: Joi.number().integer().positive().required(), + PAYMENT_POLL_INTERVAL_MS: Joi.number().integer().positive().required(), + PAYMENT_POLL_MAX_ATTEMPTS: Joi.number().integer().positive().required(), + THROTTLE_TTL_MS: Joi.number().integer().positive().required(), + THROTTLE_LIMIT: Joi.number().integer().positive().required(), +}).unknown(true); diff --git a/src/main.ts b/src/main.ts index f76bc8d..8545619 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,66 @@ +import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; +import compression from 'compression'; import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + const app = await NestFactory.create(AppModule, { + logger: ['log', 'error', 'warn', 'debug'], + }); + + const configService = app.get(ConfigService); + const nodeEnv = configService.get('app.nodeEnv') ?? 'development'; + const port = configService.get('app.port') ?? 3000; + const corsOrigins = configService.get('app.corsOrigins') ?? ''; + + app.use(helmet()); + app.use(compression()); + + const origins = corsOrigins + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + app.enableCors({ origin: origins, credentials: true }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + + app.enableVersioning({ type: VersioningType.URI }); + + let swaggerEnabled = false; + + if (nodeEnv !== 'production') { + swaggerEnabled = true; + const swaggerConfig = new DocumentBuilder() + .setTitle('Ding Payments API') + .setDescription('Self-custodial Stellar P2P payments via NFC') + .setVersion('1.0') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('/v1/docs', app, document); + } + + app.useGlobalFilters(new HttpExceptionFilter()); + app.enableShutdownHooks(); + + await app.listen(port); + + Logger.log(`🚀 Server running on http://localhost:${port}/v1`, 'Bootstrap'); + if (swaggerEnabled) { + Logger.log(`📚 Swagger docs at http://localhost:${port}/v1/docs`, 'Bootstrap'); + } } -bootstrap(); + +void bootstrap(); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index a767839..754aeb2 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,29 +1,103 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import request from 'supertest'; -import { App } from 'supertest/types'; +import { ConfigModule } from '@nestjs/config'; +import { envValidationSchema } from '../src/config/env.validation'; +import configuration from '../src/config/configuration'; +import helmet from 'helmet'; +import compression from 'compression'; +import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; import { AppModule } from './../src/app.module'; +import type { Application as ExpressApplication } from 'express'; describe('AppController (e2e)', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { + process.env.NODE_ENV = 'test'; + process.env.PORT = '3000'; + process.env.API_PREFIX = 'v1'; + process.env.CORS_ORIGINS = 'http://localhost:8081'; + process.env.DATABASE_URL = + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.DIRECT_URL = + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_JWT_SECRET = 'secret'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service_role'; + process.env.STELLAR_NETWORK = 'testnet'; + process.env.STELLAR_HORIZON_URL = 'https://horizon-testnet.stellar.org'; + process.env.STELLAR_RPC_URL = 'https://soroban-testnet.stellar.org'; + process.env.STELLAR_USDC_ISSUER = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + process.env.STELLAR_NETWORK_PASSPHRASE = + 'Test SDF Network ; September 2015'; + process.env.WEBAUTHN_RP_ID = 'localhost'; + process.env.WEBAUTHN_RP_NAME = 'Ding Payments'; + process.env.WEBAUTHN_ORIGIN = 'http://localhost:8081'; + process.env.PAYMENT_SUBMIT_TIMEOUT_MS = '300000'; + process.env.PAYMENT_POLL_INTERVAL_MS = '2000'; + process.env.PAYMENT_POLL_MAX_ATTEMPTS = '30'; + process.env.THROTTLE_TTL_MS = '60000'; + process.env.THROTTLE_LIMIT = '100'; + const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: false, allowUnknown: false }, + load: [configuration], + }), + AppModule, + ], }).compile(); app = moduleFixture.createNestApplication(); + app.use(helmet()); + app.use(compression()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.enableVersioning({ type: VersioningType.URI }); + app.useGlobalFilters(new HttpExceptionFilter()); + await app.init(); }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + it('should return JSON 404 for an unknown route', async () => { + const response = await request(app.getHttpServer()).get('/v1/nonexistent'); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + statusCode: 404, + message: 'Cannot GET /v1/nonexistent', + code: 'Not Found', + errors: {}, + path: '/v1/nonexistent', + }); + + const body = response.body as { timestamp?: unknown }; + expect(typeof body.timestamp).toBe('string'); + }); + + it('should initialize the server with valid environment variables', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); }); afterEach(async () => { - await app.close(); + if (app) { + await app.close(); + } }); }); diff --git a/test/env.setup.ts b/test/env.setup.ts new file mode 100644 index 0000000..578d395 --- /dev/null +++ b/test/env.setup.ts @@ -0,0 +1,42 @@ +export default function globalSetup(): void { + process.env.NODE_ENV = process.env.NODE_ENV ?? 'test'; + process.env.PORT = process.env.PORT ?? '3000'; + process.env.API_PREFIX = process.env.API_PREFIX ?? 'v1'; + process.env.CORS_ORIGINS = + process.env.CORS_ORIGINS ?? 'http://localhost:8081'; + process.env.DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.DIRECT_URL = + process.env.DIRECT_URL ?? + 'postgresql://postgres:password@localhost:5432/postgres'; + process.env.SUPABASE_URL = + process.env.SUPABASE_URL ?? 'https://example.supabase.co'; + process.env.SUPABASE_JWT_SECRET = process.env.SUPABASE_JWT_SECRET ?? 'secret'; + process.env.SUPABASE_SERVICE_ROLE_KEY = + process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'service_role'; + process.env.STELLAR_NETWORK = process.env.STELLAR_NETWORK ?? 'testnet'; + process.env.STELLAR_HORIZON_URL = + process.env.STELLAR_HORIZON_URL ?? 'https://horizon-testnet.stellar.org'; + process.env.STELLAR_RPC_URL = + process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + process.env.STELLAR_USDC_ISSUER = + process.env.STELLAR_USDC_ISSUER ?? + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + process.env.STELLAR_NETWORK_PASSPHRASE = + process.env.STELLAR_NETWORK_PASSPHRASE ?? + 'Test SDF Network ; September 2015'; + process.env.WEBAUTHN_RP_ID = process.env.WEBAUTHN_RP_ID ?? 'localhost'; + process.env.WEBAUTHN_RP_NAME = + process.env.WEBAUTHN_RP_NAME ?? 'Ding Payments'; + process.env.WEBAUTHN_ORIGIN = + process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:8081'; + process.env.PAYMENT_SUBMIT_TIMEOUT_MS = + process.env.PAYMENT_SUBMIT_TIMEOUT_MS ?? '300000'; + process.env.PAYMENT_POLL_INTERVAL_MS = + process.env.PAYMENT_POLL_INTERVAL_MS ?? '2000'; + process.env.PAYMENT_POLL_MAX_ATTEMPTS = + process.env.PAYMENT_POLL_MAX_ATTEMPTS ?? '30'; + process.env.THROTTLE_TTL_MS = process.env.THROTTLE_TTL_MS ?? '60000'; + process.env.THROTTLE_LIMIT = process.env.THROTTLE_LIMIT ?? '100'; +} diff --git a/test/env.validation.spec.ts b/test/env.validation.spec.ts new file mode 100644 index 0000000..513da42 --- /dev/null +++ b/test/env.validation.spec.ts @@ -0,0 +1,111 @@ +import { envValidationSchema } from '../src/config/env.validation'; + +describe('Environment validation schema', () => { + it('should validate a full environment successfully', () => { + const env = { + NODE_ENV: 'development', + PORT: '3000', + API_PREFIX: 'v1', + CORS_ORIGINS: 'http://localhost:8081', + DATABASE_URL: 'postgresql://postgres:password@localhost:5432/postgres', + DIRECT_URL: 'postgresql://postgres:password@localhost:5432/postgres', + SUPABASE_URL: 'https://example.supabase.co', + SUPABASE_JWT_SECRET: 'secret', + SUPABASE_SERVICE_ROLE_KEY: 'service_role', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_USDC_ISSUER: + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + WEBAUTHN_RP_ID: 'localhost', + WEBAUTHN_RP_NAME: 'Ding Payments', + WEBAUTHN_ORIGIN: 'http://localhost:8081', + PAYMENT_SUBMIT_TIMEOUT_MS: '300000', + PAYMENT_POLL_INTERVAL_MS: '2000', + PAYMENT_POLL_MAX_ATTEMPTS: '30', + THROTTLE_TTL_MS: '60000', + THROTTLE_LIMIT: '100', + }; + + const { error } = envValidationSchema.validate(env, { + abortEarly: false, + allowUnknown: false, + }); + + expect(error).toBeUndefined(); + }); + + it('should return a descriptive error when PORT is missing', () => { + const env = { + NODE_ENV: 'development', + API_PREFIX: 'v1', + CORS_ORIGINS: 'http://localhost:8081', + DATABASE_URL: 'postgresql://postgres:password@localhost:5432/postgres', + DIRECT_URL: 'postgresql://postgres:password@localhost:5432/postgres', + SUPABASE_URL: 'https://example.supabase.co', + SUPABASE_JWT_SECRET: 'secret', + SUPABASE_SERVICE_ROLE_KEY: 'service_role', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_USDC_ISSUER: + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + WEBAUTHN_RP_ID: 'localhost', + WEBAUTHN_RP_NAME: 'Ding Payments', + WEBAUTHN_ORIGIN: 'http://localhost:8081', + PAYMENT_SUBMIT_TIMEOUT_MS: '300000', + PAYMENT_POLL_INTERVAL_MS: '2000', + PAYMENT_POLL_MAX_ATTEMPTS: '30', + THROTTLE_TTL_MS: '60000', + THROTTLE_LIMIT: '100', + }; + + const { error } = envValidationSchema.validate(env, { + abortEarly: false, + allowUnknown: false, + }); + + expect(error).toBeDefined(); + expect(error?.message).toContain('"PORT" is required'); + }); + + it('should return a descriptive error when NODE_ENV is invalid', () => { + const env = { + NODE_ENV: 'invalid', + PORT: '3000', + API_PREFIX: 'v1', + CORS_ORIGINS: 'http://localhost:8081', + DATABASE_URL: 'postgresql://postgres:password@localhost:5432/postgres', + DIRECT_URL: 'postgresql://postgres:password@localhost:5432/postgres', + SUPABASE_URL: 'https://example.supabase.co', + SUPABASE_JWT_SECRET: 'secret', + SUPABASE_SERVICE_ROLE_KEY: 'service_role', + STELLAR_NETWORK: 'testnet', + STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org', + STELLAR_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_USDC_ISSUER: + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + WEBAUTHN_RP_ID: 'localhost', + WEBAUTHN_RP_NAME: 'Ding Payments', + WEBAUTHN_ORIGIN: 'http://localhost:8081', + PAYMENT_SUBMIT_TIMEOUT_MS: '300000', + PAYMENT_POLL_INTERVAL_MS: '2000', + PAYMENT_POLL_MAX_ATTEMPTS: '30', + THROTTLE_TTL_MS: '60000', + THROTTLE_LIMIT: '100', + }; + + const { error } = envValidationSchema.validate(env, { + abortEarly: false, + allowUnknown: false, + }); + + expect(error).toBeDefined(); + expect(error?.message).toContain( + '"NODE_ENV" must be one of [development, production, test]', + ); + }); +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..a1d72da 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,6 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "globalSetup": "/env.setup.ts" } diff --git a/tmp-lines.txt b/tmp-lines.txt new file mode 100644 index 0000000000000000000000000000000000000000..233686c0ecd9aaa2c17f976945af0ab7ad0b9812 GIT binary patch literal 7866 zcmc(kYf~G?5{Bn%PF4PczCo%I5pI%jDyd*(8)Co&3CN{RQ3}YmjzRb!e2dF}J<0pb zZ12oyS4hd16t&W7cc#0i`|Y=ThClyUb)UJByLPYLjk|SsuHioF>bs$v=+~FY{m6~o z3wNM#m+piExGSCCd~Yw=C4RX_!Z_Zxo_O(?mup=MkmkL^}M0K zJ$G2l+m>CgrEBCa+(6Ps(lnM;jNT|lpXv=GE|U>Q`u$om!pO~Xdk=bG+{>3&(+7BuX5Pv7ayjba3A5~F2(uj%?iiZjv))%p34%@`A8DU>}Btt55mKRMpP|X(;CBUB;bo1k3Z1e zl$?;`hwho{O6Iw)L-7c!p(0E(+?r>9B;6+$U|v+tiahY}<2Bd>neEM9ZBALiLKGhL_;Im%5{tK5!ka zpp)sf%5?xt5_m^Uf^)=J16vtiasdb0j# zMqEI?;bBsE9wr(1+CWA(<#D=zfpQZ6^Mtx!-URXg#D*W-nfEM6+3}u^S%eli3OhouLFXTTuZ`A?N!3{Mo@Zd1fPYs>#>%OG!>u+0{ z&c!f~w8}G|iBC&i#x?Yy=?bl{M9b`G<{Ea3EdNE{hZ$1qVoQ$0{lDvb?7k{lFIYmX zu?_woXk62ejvd*0*~|VN3y+1qow=4xysjIMvVUUPA`I12EYj$lh^M;Vwmsc}9k2o- z;H!04<*O%Rw}+aup=!D(rfw@S<~yw(MNrOnS1))m34uo<7%s%))y8) zL_>4BvbP~?L2O{)SNi>pe;0Z@I&bPWeZ!hqVKuXV5vDqmU#K1@^2n}7GuSzor@N9F z^0Zp7j3G7_Vxn0(h{J*Oo1U(+gneW~77WzA(QA*j_DLC&jfMJ+s=l-;n>OR%#cT4$ zg8j+1P{+Cs<cFk)vB{s5KK59Ea!67F)(wTgAbE^s4b zl3C1NmA>!eL#zxZ>_StyumW7Qp?)n!hWb1%GB@3!u>X2546|1qs@li%le#k#@@=48 zA7~aFU~EABXFj{#VfnNwVY%2LZ211(DktCR-iWTF;ePe2*wotMDr`pBnN&{|*ymBr zj`evXTTI_(mX{_a=$)t=hC%3M=rkLeck53->;xvQ^6A{Vvw`%)0a=TkO_-)Mm*#1D zrhdS7(ZOTvwCTz4z@5-dy}lApVaId7IyxHmY^; z&;6n})7va}o8)OkTiCURJe!>}IrgcB)W=})^yy#J*~$!?^n-n^7$#k^7&~N*cs$Rw!kGVu}Sm`ex^Hor|-H`H=AwQ*HYEbXCvXARtbx+ z(vkOBHTPN>OEQbUkmJ8;)}^WkymnTRH7qch=WFFO>GgR$L`~3656xnUxhk(^~hSjuMnH{VW&&Oj(7{?7JGW<7-`pS6G+#Z}b@%m)S@n z55jpGJL=gdTI~#nfp0@UjujKJTd4E=hhw^0Ga{brhs^d|5tn(w6Nq4X>lF*4kCvTk;sR4VPIVr3E^dGAtNjrlqm7GVuyWtNcnoW7D_ z0W0RIX})Bq!8zB!`;75R;tlYG$LPeKsNT~P&hw;cn69!Q$!uBjtcujfdJ0}IJvPa% zPMHhFfgl15!(LeSvFsW;8CDrjzY^Ah7pPUtNd4UOtNI`h_B5Jv33go89ds&ardXdGQ6dJ<)obdPZ(A~e^(d18X0#fk6RXOm261N>ZDli z&SHnfv=|0cZ`40-+-+@{+EJ%Q)DF8T30PD6irT~ a(~glnS9fPR`=qXcuLn5pZo=I){JTZ literal 0 HcmV?d00001