Skip to content

Commit 1599f4a

Browse files
authored
BC-11414 - Refactor Tldraw Server ConfigurationModule to Use Injection Token Register Approach (#101)
1 parent 8239368 commit 1599f4a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+1931
-583
lines changed
File renamed without changes.

src/infra/auth-guard/auth-guard.module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { Module } from '@nestjs/common';
22
import { PassportModule } from '@nestjs/passport';
33
import { ConfigurationModule } from '../configuration/configuration.module.js';
44
import { XApiKeyStrategy } from './strategy/index.js';
5-
import { XApiKeyConfig } from './x-api-key.config.js';
5+
import { X_API_KEY_CONFIG, XApiKeyConfig } from './x-api-key.config.js';
66

77
@Module({
8-
imports: [PassportModule, ConfigurationModule.register(XApiKeyConfig)],
8+
imports: [PassportModule, ConfigurationModule.register(X_API_KEY_CONFIG, XApiKeyConfig)],
99
providers: [XApiKeyStrategy],
1010
exports: [],
1111
})

src/infra/auth-guard/strategy/x-api-key.strategy.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createMock } from '@golevelup/ts-jest';
22
import { UnauthorizedException } from '@nestjs/common';
33
import { Test, TestingModule } from '@nestjs/testing';
4-
import { XApiKeyConfig } from '../x-api-key.config.js';
4+
import { X_API_KEY_CONFIG, XApiKeyConfig } from '../x-api-key.config.js';
55
import { XApiKeyStrategy } from './x-api-key.strategy.js';
66

77
describe('XApiKeyStrategy', () => {
@@ -15,14 +15,14 @@ describe('XApiKeyStrategy', () => {
1515
providers: [
1616
XApiKeyStrategy,
1717
{
18-
provide: XApiKeyConfig,
18+
provide: X_API_KEY_CONFIG,
1919
useValue: createMock<XApiKeyConfig>(),
2020
},
2121
],
2222
}).compile();
2323

2424
strategy = module.get(XApiKeyStrategy);
25-
config = module.get(XApiKeyConfig);
25+
config = module.get(X_API_KEY_CONFIG);
2626
});
2727

2828
afterAll(async () => {
@@ -37,7 +37,7 @@ describe('XApiKeyStrategy', () => {
3737
describe('when a valid api key is provided', () => {
3838
const setup = () => {
3939
const CORRECT_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4';
40-
config.X_API_ALLOWED_KEYS = [CORRECT_API_KEY];
40+
config.xApiAllowedKeys = [CORRECT_API_KEY];
4141

4242
return { CORRECT_API_KEY };
4343
};
@@ -50,7 +50,7 @@ describe('XApiKeyStrategy', () => {
5050
describe('when an invalid api key is provided', () => {
5151
const setup = () => {
5252
const INVALID_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4BAD';
53-
config.X_API_ALLOWED_KEYS = ['some-other-key'];
53+
config.xApiAllowedKeys = ['some-other-key'];
5454

5555
return { INVALID_API_KEY };
5656
};
@@ -63,7 +63,7 @@ describe('XApiKeyStrategy', () => {
6363
describe('when no api keys are allowed', () => {
6464
const setup = () => {
6565
const ANY_API_KEY = '7ccd4e11-c6f6-48b0-81eb-cccf7922e7a4';
66-
config.X_API_ALLOWED_KEYS = [];
66+
config.xApiAllowedKeys = [];
6767

6868
return { ANY_API_KEY };
6969
};

src/infra/auth-guard/strategy/x-api-key.strategy.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
22
import { PassportStrategy } from '@nestjs/passport';
33
import { Strategy } from 'passport-headerapikey/lib/Strategy.js';
44
import { StrategyType } from '../interface/index.js';
5-
import { XApiKeyConfig } from '../x-api-key.config.js';
5+
import { X_API_KEY_CONFIG, XApiKeyConfig } from '../x-api-key.config.js';
66

77
@Injectable()
88
export class XApiKeyStrategy extends PassportStrategy(Strategy, StrategyType.API_KEY) {
9-
public constructor(private readonly config: XApiKeyConfig) {
9+
public constructor(@Inject(X_API_KEY_CONFIG) private readonly config: XApiKeyConfig) {
1010
super({ header: 'X-API-KEY', prefix: '' }, false);
1111
}
1212

1313
public validate(apiKey: string): boolean {
14-
if (this.config.X_API_ALLOWED_KEYS.includes(apiKey)) {
14+
if (this.config.xApiAllowedKeys.includes(apiKey)) {
1515
return true;
1616
}
1717
throw new UnauthorizedException();
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { Transform } from 'class-transformer';
21
import { IsArray } from 'class-validator';
2+
import { CommaSeparatedStringToArray } from '../../shared/transformer/index.js';
3+
import { ConfigProperty, Configuration } from '../configuration/index.js';
34

5+
export const X_API_KEY_CONFIG = 'X_API_KEY_CONFIG';
6+
@Configuration()
47
export class XApiKeyConfig {
5-
@Transform(({ value }) => value.split(',').map((part: string) => (part.split(':').pop() ?? '').trim()))
8+
@CommaSeparatedStringToArray()
69
@IsArray()
7-
public X_API_ALLOWED_KEYS!: string[];
10+
@ConfigProperty('X_API_ALLOWED_KEYS')
11+
public xApiAllowedKeys!: string[];
812
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { IsUrl } from 'class-validator';
2+
import { ConfigProperty, Configuration } from '../configuration/index.js';
23

4+
export const AUTHORIZATION_CONFIG = 'AUTHORIZATION_CONFIG';
5+
@Configuration()
36
export class AuthorizationConfig {
47
@IsUrl({ require_tld: false })
5-
public AUTHORIZATION_API_HOST!: string;
8+
@ConfigProperty('AUTHORIZATION_API_HOST')
9+
public authorizationApiHost!: string;
610
}

src/infra/authorization/authorization.module.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@ import { Module } from '@nestjs/common';
22
import { ConfigurationModule } from '../configuration/configuration.module.js';
33
import { LoggerModule } from '../logger/logger.module.js';
44
import { AuthorizationApi, Configuration } from './authorization-api-client/index.js';
5-
import { AuthorizationConfig } from './authorization.config.js';
5+
import { AUTHORIZATION_CONFIG, AuthorizationConfig } from './authorization.config.js';
66
import { AuthorizationService } from './authorization.service.js';
77

88
@Module({
9-
imports: [LoggerModule, ConfigurationModule.register(AuthorizationConfig)],
9+
imports: [LoggerModule, ConfigurationModule.register(AUTHORIZATION_CONFIG, AuthorizationConfig)],
1010
providers: [
1111
{
1212
provide: AuthorizationApi,
1313
useFactory: (config: AuthorizationConfig): AuthorizationApi => {
14-
const apiHost = config.AUTHORIZATION_API_HOST;
14+
const apiHost = config.authorizationApiHost;
1515
const configuration = new Configuration({ basePath: `${apiHost}/api/v3` });
1616
const authorizationApi = new AuthorizationApi(configuration);
1717

1818
return authorizationApi;
1919
},
20-
inject: [AuthorizationConfig],
20+
inject: [AUTHORIZATION_CONFIG],
2121
},
2222
AuthorizationService,
2323
],
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
interface PropertyAccessKey {
3+
propertyKey: string | symbol;
4+
key?: string | symbol;
5+
}
6+
7+
export interface WithConfigurationDecorator {
8+
[key: string]: unknown;
9+
getConfigKeys(): (string | symbol)[];
10+
}
11+
12+
/**
13+
* Decorator to mark a class as a configuration class.
14+
* @returns ClassDecorator
15+
*/
16+
export function Configuration() {
17+
return function ConfigurationDecorator<T extends new (...args: any[]) => object>(constructor: T): T {
18+
return class extends constructor {
19+
public constructor(...args: any[]) {
20+
super(...args);
21+
const proxyInstance = new Proxy(this, {
22+
set: (target: any, prop: string | symbol, value: unknown): true => {
23+
const propertyAccessKeys = this.getPropertyAccessKeys();
24+
const propKey = propertyAccessKeys.find((item) => item.key === prop)?.propertyKey ?? prop;
25+
if (propKey) {
26+
target[propKey] = value;
27+
}
28+
29+
return true;
30+
},
31+
get: (target: any, prop: string | symbol, receiver: any): any => {
32+
const value = Reflect.get(target, prop, receiver);
33+
34+
return value;
35+
},
36+
});
37+
38+
return proxyInstance;
39+
}
40+
41+
public getConfigKeys(): (string | symbol)[] {
42+
const objectKeys = Object.keys(this);
43+
const propertyAccessKeys = this.getPropertyAccessKeys();
44+
const keys = propertyAccessKeys.map((item) => item.key ?? item.propertyKey);
45+
46+
for (const key of objectKeys) {
47+
if (!propertyAccessKeys.some((item) => item.propertyKey === key)) {
48+
keys.push(key);
49+
}
50+
}
51+
52+
return keys;
53+
}
54+
55+
private getPropertyAccessKeys(): PropertyAccessKey[] {
56+
const proto = Object.getPrototypeOf(this) as { __propertyAccessKeys?: PropertyAccessKey[] };
57+
const propertyAccessKeys = proto.__propertyAccessKeys ?? [];
58+
59+
return propertyAccessKeys;
60+
}
61+
};
62+
};
63+
}
64+
65+
/**
66+
* Decorator to mark a class property as a configuration property.
67+
* @param key Optional environment variable name to map to the decorated property.If not provided, the property name will be used.
68+
* @returns PropertyDecorator
69+
*/
70+
export function ConfigProperty(key?: string): PropertyDecorator {
71+
return function (target: { __propertyAccessKeys?: PropertyAccessKey[] }, propertyKey: string | symbol) {
72+
target.__propertyAccessKeys ??= [];
73+
target.__propertyAccessKeys.push({ propertyKey, key });
74+
};
75+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/* eslint-disable max-classes-per-file */
2+
import { createMock, DeepMocked } from '@golevelup/ts-jest';
3+
import { ConfigService } from '@nestjs/config';
4+
import { Test, TestingModule } from '@nestjs/testing';
5+
import { IsBoolean, IsString } from 'class-validator';
6+
import { StringToBoolean } from '../../shared/transformer/index.js';
7+
import { ConfigProperty, Configuration } from './configuration.decorator.js';
8+
import { ConfigurationFactory } from './configuration.factory.js';
9+
10+
@Configuration()
11+
class TestConfig {
12+
@IsString()
13+
@ConfigProperty('TEST_VALUE_1')
14+
public valueWithOtherEnvVar!: string;
15+
16+
@IsString()
17+
@ConfigProperty('TEST_VALUE2')
18+
public valueWithOtherEnvVarAndDefault = 'default';
19+
20+
@IsString()
21+
@ConfigProperty()
22+
public valueWithoutDefault!: string;
23+
24+
@IsBoolean()
25+
@StringToBoolean()
26+
@ConfigProperty()
27+
public valueWithoutDefaultBoolean!: boolean;
28+
29+
@IsBoolean()
30+
public valueWithDefaultBoolean = true;
31+
}
32+
33+
describe(ConfigurationFactory.name, () => {
34+
let module: TestingModule;
35+
let configFactory: ConfigurationFactory;
36+
let configService: DeepMocked<ConfigService>;
37+
38+
beforeAll(async () => {
39+
module = await Test.createTestingModule({
40+
providers: [
41+
{
42+
provide: ConfigService,
43+
useValue: createMock<ConfigService>(),
44+
},
45+
],
46+
}).compile();
47+
48+
configService = module.get(ConfigService);
49+
configFactory = new ConfigurationFactory(configService);
50+
});
51+
52+
afterAll(async () => {
53+
await module.close();
54+
});
55+
56+
afterEach(() => {
57+
jest.resetAllMocks();
58+
});
59+
60+
describe('loadAndValidateConfigs', () => {
61+
describe('when value is valid', () => {
62+
const setup = () => {
63+
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
64+
if (key === 'TEST_VALUE_1') {
65+
return 'test';
66+
}
67+
if (key === 'TEST_VALUE2') {
68+
return 'test2';
69+
}
70+
if (key === 'valueWithoutDefault') {
71+
return 'testValueWithD';
72+
}
73+
if (key === 'valueWithoutDefaultBoolean') {
74+
return 'true';
75+
}
76+
77+
return undefined;
78+
});
79+
};
80+
describe('when @ConfigProperty("TEST_VALUE_1") is provided by env variable', () => {
81+
it('should return the correct value', () => {
82+
setup();
83+
const result = configFactory.loadAndValidateConfigs(TestConfig);
84+
85+
expect(result.valueWithOtherEnvVar).toEqual('test');
86+
});
87+
});
88+
89+
describe('when @ConfigProperty("TEST_VALUE2") is provided by env variable', () => {
90+
it('should return the correct value', () => {
91+
setup();
92+
const result = configFactory.loadAndValidateConfigs(TestConfig);
93+
94+
expect(result.valueWithOtherEnvVarAndDefault).toEqual('test2');
95+
});
96+
});
97+
98+
describe('when @ConfigProperty() without default is provided by env variable', () => {
99+
it('should return the correct value', () => {
100+
setup();
101+
const result = configFactory.loadAndValidateConfigs(TestConfig);
102+
103+
expect(result.valueWithoutDefault).toEqual('testValueWithD');
104+
});
105+
});
106+
107+
describe('when @ConfigProperty() without default is provided by env variable and transformed to boolean', () => {
108+
it('should return the correct value', () => {
109+
setup();
110+
const result = configFactory.loadAndValidateConfigs(TestConfig);
111+
112+
expect(result.valueWithoutDefaultBoolean).toEqual(true);
113+
});
114+
});
115+
116+
describe('when @ConfigProperty() with default is not provided by env variable', () => {
117+
it('should return the default value', () => {
118+
setup();
119+
const result = configFactory.loadAndValidateConfigs(TestConfig);
120+
121+
expect(result.valueWithDefaultBoolean).toEqual(true);
122+
});
123+
});
124+
});
125+
126+
describe('when value is not valid', () => {
127+
const setup = () => {
128+
jest.spyOn(configService, 'get').mockImplementation((key: string) => {
129+
if (key === 'TEST_VALUE_1') {
130+
return 123;
131+
}
132+
if (key === 'TEST_VALUE2') {
133+
return 'test2';
134+
}
135+
if (key === 'testValueWithD') {
136+
return 'testValueWithD';
137+
}
138+
if (key === 'testValueWith') {
139+
return 'true';
140+
}
141+
142+
return undefined;
143+
});
144+
};
145+
it('should throw error', () => {
146+
setup();
147+
expect(() => configFactory.loadAndValidateConfigs(TestConfig)).toThrow(/isString/);
148+
});
149+
});
150+
151+
describe('when The class is not decorated with @Configuration()', () => {
152+
class InvalidConfig {
153+
@IsString()
154+
@ConfigProperty('TEST_VALUE_1')
155+
public TEST_VALUE!: string;
156+
}
157+
158+
it('should throw error', () => {
159+
expect(() => configFactory.loadAndValidateConfigs(InvalidConfig)).toThrow(
160+
`The class InvalidConfig is not decorated with @Configuration()`,
161+
);
162+
});
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)