Skip to content

Commit 50d06be

Browse files
committed
feat(): durable providers #2 - i18n
1 parent 9e09ee3 commit 50d06be

File tree

11 files changed

+180
-6
lines changed

11 files changed

+180
-6
lines changed

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
"@nestjs/event-emitter": "^3.0.1",
2727
"@nestjs/mapped-types": "*",
2828
"@nestjs/platform-express": "^11.0.1",
29+
"accept-language-parser": "^1.5.0",
2930
"piscina": "5.0.0-alpha.0",
3031
"reflect-metadata": "^0.2.2",
31-
"rxjs": "^7.8.1"
32+
"rxjs": "^7.8.1",
33+
"string-format": "^2.0.0"
3234
},
3335
"devDependencies": {
3436
"@eslint/eslintrc": "^3.2.0",
@@ -38,9 +40,11 @@
3840
"@nestjs/testing": "^11.0.1",
3941
"@swc/cli": "^0.6.0",
4042
"@swc/core": "^1.10.7",
43+
"@types/accept-language-parser": "^1.5.7",
4144
"@types/express": "^5.0.0",
4245
"@types/jest": "^29.5.14",
4346
"@types/node": "^22.10.7",
47+
"@types/string-format": "^2.0.3",
4448
"@types/supertest": "^6.0.2",
4549
"eslint": "^9.18.0",
4650
"eslint-config-prettier": "^10.0.1",

pnpm-lock.yaml

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import { PaymentsModule } from './payments/payments.module'
1313
import { DataSourceModule } from './data-source/data-source.module'
1414
import { UsersModule } from './users/users.module'
1515
import { ContextIdFactory } from '@nestjs/core'
16-
import { AggregateByTenantContextIdStrategy } from './core/aggregate-by-tenant.strategy'
16+
import { I18nModule } from './i18n/i18n.module'
17+
import { AggregateByLocaleContextIdStrategy } from './core/aggregate-by-locale.strategy'
1718

18-
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy())
19+
ContextIdFactory.apply(new AggregateByLocaleContextIdStrategy())
1920

2021
@Module({
2122
imports: [
@@ -37,6 +38,7 @@ ContextIdFactory.apply(new AggregateByTenantContextIdStrategy())
3738
PaymentsModule,
3839
DataSourceModule,
3940
UsersModule,
41+
I18nModule,
4042
// Alternatively, we can use the `forRootAsync` method to register the module with dynamic options
4143
// HttpClientModule.forRootAsync({
4244
// useFactory: () => ({ baseUrl: 'https://nestjs.com' }),

src/app.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common'
2+
import { I18nService } from './i18n/i18n.service'
23

34
@Injectable()
45
export class AppService {
6+
constructor(private readonly i18nService: I18nService) {}
7+
58
getHello(): string {
6-
return 'Hello World!';
9+
return this.i18nService.translate('ERRORS.USER_NOT_FOUND', {
10+
firstName: 'John',
11+
})
712
}
813
}

src/assets/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"HELLO": "Hello {firstName}",
3+
"ERRORS": {
4+
"USER_NOT_FOUND": "User {firstName} does not exist"
5+
}
6+
}

src/assets/locales/pl.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"HELLO": "Czesc {firstName}",
3+
"ERRORS": {
4+
"USER_NOT_FOUND": "Uzytkownik {firstName} nie istnieje"
5+
}
6+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
ContextId,
3+
ContextIdFactory,
4+
ContextIdResolver,
5+
ContextIdResolverFn,
6+
ContextIdStrategy,
7+
HostComponentInfo,
8+
} from '@nestjs/core'
9+
import { pick } from 'accept-language-parser'
10+
import { Request } from 'express'
11+
import { I18nService } from 'src/i18n/i18n.service'
12+
13+
export class AggregateByLocaleContextIdStrategy implements ContextIdStrategy {
14+
// A collection of context identifiers representing separate DI sub-trees per locale
15+
private readonly locales = new Map<string, ContextId>()
16+
17+
attach(
18+
contextId: ContextId,
19+
request: Request,
20+
): ContextIdResolverFn | ContextIdResolver {
21+
const localeCode =
22+
pick(
23+
I18nService.supportedLanguages,
24+
request.headers['accept-language']!,
25+
) ?? I18nService.defaultLanguage
26+
27+
let localeSubTreeId: ContextId
28+
29+
if (this.locales.has(localeCode)) {
30+
localeSubTreeId = this.locales.get(localeCode)!
31+
} else {
32+
// Construct a new context id
33+
localeSubTreeId = ContextIdFactory.create()
34+
this.locales.set(localeCode, localeSubTreeId)
35+
36+
// we can remove the locale context id after a certain period of time
37+
// if not UserService instance is created once per locale
38+
setTimeout(() => this.locales.delete(localeCode), 3000)
39+
}
40+
41+
return {
42+
payload: { localeCode },
43+
resolve: (info: HostComponentInfo) =>
44+
info.isTreeDurable ? localeSubTreeId : contextId,
45+
}
46+
}
47+
}

src/i18n/i18n.module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common'
2+
import { I18nService } from './i18n.service'
3+
4+
@Module({
5+
providers: [I18nService],
6+
exports: [I18nService],
7+
})
8+
export class I18nModule {}

src/i18n/i18n.service.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { I18nService } from './i18n.service';
3+
4+
describe('I18nService', () => {
5+
let service: I18nService;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [I18nService],
10+
}).compile();
11+
12+
service = module.get<I18nService>(I18nService);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(service).toBeDefined();
17+
});
18+
});

src/i18n/i18n.service.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Inject, Injectable, Scope } from '@nestjs/common'
2+
import type * as Schema from '../assets/locales/en.json'
3+
import * as en from '../assets/locales/en.json'
4+
import * as pl from '../assets/locales/pl.json'
5+
import { REQUEST } from '@nestjs/core'
6+
import format from 'string-format'
7+
8+
type PathsToStringProps<T> = T extends string
9+
? []
10+
: {
11+
[K in Extract<keyof T, string>]: [K, ...PathsToStringProps<T[K]>]
12+
}[Extract<keyof T, string>]
13+
14+
type Join<T extends string[]> = T extends []
15+
? never
16+
: T extends [infer F]
17+
? F
18+
: T extends [infer F, ...infer R]
19+
? F extends string
20+
? `${F}.${Join<Extract<R, string[]>>}`
21+
: never
22+
: string
23+
24+
@Injectable({ scope: Scope.REQUEST, durable: true })
25+
export class I18nService {
26+
constructor(
27+
@Inject(REQUEST) private readonly payload: { localeCode: string },
28+
) {}
29+
30+
public static readonly defaultLanguage = 'en'
31+
public static readonly supportedLanguages = ['en', 'pl']
32+
private readonly locales: Record<string, typeof Schema> = { en, pl }
33+
34+
translate(
35+
key: Join<PathsToStringProps<typeof Schema>>,
36+
...args: Array<string | Record<string, unknown>>
37+
): string {
38+
const locale =
39+
this.locales[this.payload.localeCode ?? I18nService.defaultLanguage]
40+
41+
// To support dot notation: "ERRORS.USER_NOT_FOUND"
42+
const text: string = key.split('.').reduce((o, i) => o[i], locale)
43+
return format(text, ...args)
44+
}
45+
}

0 commit comments

Comments
 (0)