diff --git a/.env.example b/.env.example index 2f047e5..3980243 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,6 @@ APP_PORT=3000 APP_NAME="NestJS API" API_PREFIX=api APP_FALLBACK_LANGUAGE=en -APP_HEADER_LANGUAGE=en DATABASE_TYPE=postgres DATABASE_HOST=localhost diff --git a/nest-cli.json b/nest-cli.json index f9aa683..d28cb54 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [{ "include": "i18n/**/*", "watchAssets": true }] } } diff --git a/package.json b/package.json index 6b87d47..6794672 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "argon2": "0.40.3", "class-transformer": "0.5.1", "class-validator": "0.14.1", + "nestjs-i18n": "10.4.5", "pg": "8.12.0", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afded6f..a6bbae2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: class-validator: specifier: 0.14.1 version: 0.14.1 + nestjs-i18n: + specifier: 10.4.5 + version: 10.4.5(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-validator@0.14.1)(rxjs@7.8.1) pg: specifier: 8.12.0 version: 8.12.0 @@ -1526,6 +1529,9 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accept-language-parser@1.5.0: + resolution: {integrity: sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2087,6 +2093,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -4013,6 +4023,15 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nestjs-i18n@10.4.5: + resolution: {integrity: sha512-OamX+Bf7yGtbKnfMLQNaVZL89YmFfcYCf0Y+R282D3rExpXDvHNNVrSbJoOXPK2fBq7/lnhlVYwEHiniqOiqXw==} + engines: {node: '>=16'} + peerDependencies: + '@nestjs/common': '*' + '@nestjs/core': '*' + class-validator: '*' + rxjs: '*' + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -5128,6 +5147,9 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-format@2.0.0: + resolution: {integrity: sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -7739,6 +7761,8 @@ snapshots: abbrev@1.1.1: {} + accept-language-parser@1.5.0: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -8397,6 +8421,8 @@ snapshots: cookie-signature@1.0.6: {} + cookie@0.5.0: {} + cookie@0.6.0: {} cookiejar@2.1.4: {} @@ -10614,6 +10640,19 @@ snapshots: neo-async@2.6.2: {} + nestjs-i18n@10.4.5(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-validator@0.14.1)(rxjs@7.8.1): + dependencies: + '@nestjs/common': 10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) + accept-language-parser: 1.5.0 + chokidar: 3.6.0 + class-validator: 0.14.1 + cookie: 0.5.0 + iterare: 1.2.1 + js-yaml: 4.1.0 + rxjs: 7.8.1 + string-format: 2.0.0 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -11848,6 +11887,8 @@ snapshots: string-argv@0.3.2: {} + string-format@2.0.0: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts index 1346677..1671ccb 100644 --- a/src/api/user/user.controller.ts +++ b/src/api/user/user.controller.ts @@ -12,7 +12,10 @@ import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserDto } from './dto/user.dto'; -@Controller('users') +@Controller({ + path: 'users', + version: '1', +}) export class UserController { constructor(private readonly userService: UserService) {} diff --git a/src/api/user/user.service.ts b/src/api/user/user.service.ts index ec158e8..3954dc7 100644 --- a/src/api/user/user.service.ts +++ b/src/api/user/user.service.ts @@ -6,6 +6,7 @@ import { UserEntity } from './entities/user.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { plainToInstance } from 'class-transformer'; import { UserDto } from './dto/user.dto'; +import { I18nContext } from 'nestjs-i18n'; @Injectable() export class UserService { @@ -14,6 +15,8 @@ export class UserService { private readonly userRepository: Repository, ) {} async create(dto: CreateUserDto): Promise { + const i18n = I18nContext.current(); + const { username, email, password } = dto; // check uniqueness of username/email @@ -29,7 +32,7 @@ export class UserService { }); if (user) { - throw new Error('User already exists'); + throw new Error(i18n.t('validation.user.errors.userAlreadyExists')); } const newUser = new UserEntity({ diff --git a/src/config/app-config.type.ts b/src/config/app-config.type.ts index 804ceda..7a0772f 100644 --- a/src/config/app-config.type.ts +++ b/src/config/app-config.type.ts @@ -4,5 +4,4 @@ export type AppConfig = { port: number; apiPrefix: string; fallbackLanguage: string; - headerLanguage: string; }; diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 37eac5c..39ae0b8 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -29,10 +29,6 @@ class EnvironmentVariablesValidator { @IsString() @IsOptional() APP_FALLBACK_LANGUAGE: string; - - @IsString() - @IsOptional() - APP_HEADER_LANGUAGE: string; } export default registerAs('app', () => { @@ -47,7 +43,6 @@ export default registerAs('app', () => { ? parseInt(process.env.PORT, 10) : 3000, apiPrefix: process.env.API_PREFIX || 'api', - fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en', - headerLanguage: process.env.HEADER_LANGUAGE || 'x-lang', + fallbackLanguage: process.env.APP_FALLBACK_LANGUAGE || 'en', }; }); diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/i18n/en/common.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/en/validation.json b/src/i18n/en/validation.json new file mode 100644 index 0000000..b1ce429 --- /dev/null +++ b/src/i18n/en/validation.json @@ -0,0 +1,7 @@ +{ + "user": { + "errors": { + "userAlreadyExists": "User already exists" + } + } +} diff --git a/src/i18n/jp/common.json b/src/i18n/jp/common.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/i18n/jp/common.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/jp/validation.json b/src/i18n/jp/validation.json new file mode 100644 index 0000000..f985732 --- /dev/null +++ b/src/i18n/jp/validation.json @@ -0,0 +1,7 @@ +{ + "user": { + "errors": { + "userAlreadyExists": "このユーザーは既に存在します" + } + } +} diff --git a/src/i18n/vi/common.json b/src/i18n/vi/common.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/i18n/vi/common.json @@ -0,0 +1 @@ +{} diff --git a/src/i18n/vi/validation.json b/src/i18n/vi/validation.json new file mode 100644 index 0000000..c565475 --- /dev/null +++ b/src/i18n/vi/validation.json @@ -0,0 +1,7 @@ +{ + "user": { + "errors": { + "userAlreadyExists": "Người dùng đã tồn tại" + } + } +} diff --git a/src/main.ts b/src/main.ts index 3843f4f..5c1a184 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,13 +2,28 @@ import { NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { AllConfigType } from './config/config.type'; -import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; +import { + ClassSerializerInterceptor, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + app.setGlobalPrefix( + configService.getOrThrow('app.apiPrefix', { infer: true }), + { + exclude: ['/'], + }, + ); + + app.enableVersioning({ + type: VersioningType.URI, + }); + app.useGlobalPipes(new ValidationPipe()); app.useGlobalInterceptors( new ClassSerializerInterceptor(app.get(Reflector), { diff --git a/src/utils/modules-set.ts b/src/utils/modules-set.ts index a3c41f9..5bd48ef 100644 --- a/src/utils/modules-set.ts +++ b/src/utils/modules-set.ts @@ -1,11 +1,19 @@ import { ModuleMetadata } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { ApiModule } from '../api/api.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import appConfig from '../config/app.config'; import { TypeOrmConfigService } from '../database/typeorm-config.service'; import { DataSource, DataSourceOptions } from 'typeorm'; import databaseConfig from '../database/config/database.config'; +import { + AcceptLanguageResolver, + HeaderResolver, + I18nModule, + QueryResolver, +} from 'nestjs-i18n'; +import { AllConfigType } from '@/config/config.type'; +import * as path from 'path'; function generateModulesSet() { const imports: ModuleMetadata['imports'] = [ @@ -28,14 +36,37 @@ function generateModulesSet() { }, }); + const i18nModule = I18nModule.forRootAsync({ + resolvers: [ + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + new HeaderResolver(['x-lang']), + ], + useFactory: (configService: ConfigService) => { + const isDevelopment = + configService.get('app.nodeEnv', { infer: true }) === 'development'; + return { + fallbackLanguage: configService.getOrThrow('app.fallbackLanguage', { + infer: true, + }), + loaderOptions: { + path: path.join(__dirname, '/../i18n/'), + watch: isDevelopment, + }, + logging: isDevelopment, + }; + }, + inject: [ConfigService], + }); + const modulesSet = process.env.MODULES_SET || 'monolith'; switch (modulesSet) { case 'monolith': - customModules = [ApiModule, dbModule]; + customModules = [ApiModule, dbModule, i18nModule]; break; case 'api': - customModules = [ApiModule, dbModule]; + customModules = [ApiModule, dbModule, i18nModule]; break; default: console.error(`Unsupported modules set: ${modulesSet}`);