Skip to content

Commit f441c52

Browse files
committed
refactor: replace configuration imports with ConfigService for better dependency management
- Updated various guards, strategies, services, and controllers to use ConfigService instead of direct configuration imports. - Introduced a private `config` getter in multiple classes to access application configuration. - Ensured that testing flags and other configuration values are accessed through the ConfigService for improved testability and maintainability.
1 parent 81e6263 commit f441c52

35 files changed

Lines changed: 488 additions & 157 deletions

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@nestjs/common": "^11.1.13",
30+
"@nestjs/config": "^4.0.3",
3031
"@nestjs/core": "^11.1.13",
3132
"@nestjs/event-emitter": "^3.0.1",
3233
"@nestjs/jwt": "^11.0.2",
@@ -80,7 +81,8 @@
8081
"unidecode": "^1.1.0",
8182
"winston": "^3.19.0",
8283
"winston-console-format": "^1.0.8",
83-
"winston-daily-rotate-file": "^5.0.0"
84+
"winston-daily-rotate-file": "^5.0.0",
85+
"yaml": "^2.8.2"
8486
},
8587
"devDependencies": {
8688
"@eslint/compat": "^2.0.2",

pnpm-lock.yaml

Lines changed: 41 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: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { Module } from "@nestjs/common";
2+
import {
3+
ConfigService,
4+
ConfigModule as NestConfigModule,
5+
} from "@nestjs/config";
26
import { APP_INTERCEPTOR } from "@nestjs/core";
37
import { EventEmitterModule } from "@nestjs/event-emitter";
48
import { ScheduleModule } from "@nestjs/schedule";
5-
import configuration from "./configuration";
9+
import configuration, { gamevaultConfiguration } from "./configuration";
10+
import { GAMEVAULT_CONFIG, getGamevaultConfig } from "./gamevault-config";
611
import { DisableApiIfInterceptor } from "./interceptors/disable-api-if.interceptor";
712
import { HttpLoggingInterceptor } from "./interceptors/http-logging.interceptor";
813
import { AdminModule } from "./modules/admin/admin.module";
914
import { AuthModule } from "./modules/auth/auth.module";
10-
import { ConfigModule } from "./modules/config/config.module";
15+
import { ConfigModule as ApiConfigModule } from "./modules/config/config.module";
1116
import { DatabaseModule } from "./modules/database/database.module";
1217
import { GamesModule } from "./modules/games/games.module";
1318
import { GarbageCollectionModule } from "./modules/garbage-collection/garbage-collection.module";
@@ -22,8 +27,12 @@ import { WebUIModule } from "./modules/web-ui/web-ui.module";
2227

2328
@Module({
2429
imports: [
30+
NestConfigModule.forRoot({
31+
isGlobal: true,
32+
load: [gamevaultConfiguration],
33+
}),
2534
OtpModule,
26-
ConfigModule,
35+
ApiConfigModule,
2736
AuthModule,
2837
DatabaseModule,
2938
MediaModule,
@@ -40,6 +49,11 @@ import { WebUIModule } from "./modules/web-ui/web-ui.module";
4049
...(configuration.WEB_UI.ENABLED ? [WebUIModule] : []),
4150
],
4251
providers: [
52+
{
53+
provide: GAMEVAULT_CONFIG,
54+
inject: [ConfigService],
55+
useFactory: getGamevaultConfig,
56+
},
4357
{
4458
provide: APP_INTERCEPTOR,
4559
useClass: DisableApiIfInterceptor,

src/configuration.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
* their effects on the configuration object and exported utilities.
55
*/
66

7+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
8+
import { tmpdir } from "os";
9+
import { join } from "path";
10+
711
import { getCensoredConfiguration } from "./configuration";
812

913
describe("Configuration", () => {
@@ -136,4 +140,44 @@ describe("Configuration", () => {
136140
expect(Array.isArray(config.MEDIA.SUPPORTED_FORMATS)).toBe(true);
137141
});
138142
});
143+
144+
describe("YAML configuration fallback", () => {
145+
let tempConfigDir: string;
146+
147+
beforeEach(() => {
148+
tempConfigDir = mkdtempSync(join(tmpdir(), "gamevault-config-"));
149+
process.env.VOLUMES_CONFIG = tempConfigDir;
150+
delete process.env.SERVER_PORT;
151+
jest.resetModules();
152+
});
153+
154+
afterEach(() => {
155+
delete process.env.VOLUMES_CONFIG;
156+
delete process.env.SERVER_PORT;
157+
rmSync(tempConfigDir, { recursive: true, force: true });
158+
});
159+
160+
it("should use YAML values when corresponding env vars are unset", async () => {
161+
writeFileSync(
162+
join(tempConfigDir, "config.yaml"),
163+
"server:\n port: 9191\n",
164+
);
165+
166+
const { default: config } = await import("./configuration");
167+
168+
expect(config.SERVER.PORT).toBe(9191);
169+
});
170+
171+
it("should prioritize env vars over YAML values", async () => {
172+
writeFileSync(
173+
join(tempConfigDir, "config.yaml"),
174+
"server:\n port: 9191\n",
175+
);
176+
process.env.SERVER_PORT = "8089";
177+
178+
const { default: config } = await import("./configuration");
179+
180+
expect(config.SERVER.PORT).toBe(8089);
181+
});
182+
});
139183
});

src/configuration.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,114 @@
1+
import { registerAs } from "@nestjs/config";
12
import bytes from "bytes";
23
import { createHash, randomBytes } from "crypto";
4+
import * as dotenv from "dotenv";
5+
import { existsSync } from "fs";
36
import { readFileSync } from "fs-extra";
47
import { toLower } from "lodash";
8+
import { join } from "path";
9+
import { parse as parseYaml } from "yaml";
510
import packageJson from "../package.json";
611
import globals from "./globals";
712

13+
dotenv.config();
14+
15+
let yamlConfigurationCache: Record<string, unknown> | null | undefined;
16+
17+
function getConfigVolumePath(): string {
18+
return process.env.VOLUMES_CONFIG?.replace(/\/$/, "") || "/config";
19+
}
20+
21+
function getYamlConfiguration(): Record<string, unknown> | null {
22+
if (yamlConfigurationCache !== undefined) {
23+
return yamlConfigurationCache;
24+
}
25+
26+
const configVolumePath = getConfigVolumePath();
27+
const candidates = ["config.yaml", "config.yml"];
28+
29+
for (const candidate of candidates) {
30+
const yamlPath = join(configVolumePath, candidate);
31+
if (!existsSync(yamlPath)) {
32+
continue;
33+
}
34+
35+
try {
36+
const parsed = parseYaml(readFileSync(yamlPath, "utf-8"));
37+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
38+
yamlConfigurationCache = parsed as Record<string, unknown>;
39+
return yamlConfigurationCache;
40+
}
41+
42+
throw new Error("Configuration root must be a YAML mapping/object.");
43+
} catch (error) {
44+
throw new Error(
45+
`Failed to parse YAML configuration at \"${yamlPath}\": ${error instanceof Error ? error.message : String(error)}`,
46+
);
47+
}
48+
}
49+
50+
yamlConfigurationCache = null;
51+
return yamlConfigurationCache;
52+
}
53+
54+
function getYamlValueByPath(
55+
source: Record<string, unknown>,
56+
pathSegments: string[],
57+
): unknown {
58+
let current: unknown = source;
59+
60+
for (const segment of pathSegments) {
61+
if (!current || typeof current !== "object" || Array.isArray(current)) {
62+
return undefined;
63+
}
64+
65+
const record = current as Record<string, unknown>;
66+
const matchedKey = Object.keys(record).find(
67+
(key) => key.toLowerCase() === segment.toLowerCase(),
68+
);
69+
70+
if (!matchedKey) {
71+
return undefined;
72+
}
73+
74+
current = record[matchedKey];
75+
}
76+
77+
return current;
78+
}
79+
80+
function toEnvironmentString(value: unknown): string | undefined {
81+
if (value === undefined || value === null) {
82+
return undefined;
83+
}
84+
if (Array.isArray(value)) {
85+
return value.map(String).join(",");
86+
}
87+
if (
88+
typeof value === "string" ||
89+
typeof value === "number" ||
90+
typeof value === "boolean"
91+
) {
92+
return String(value);
93+
}
94+
return JSON.stringify(value);
95+
}
96+
97+
function resolveYamlEnvFallback(name: string): string | undefined {
98+
const yamlConfiguration = getYamlConfiguration();
99+
if (!yamlConfiguration) {
100+
return undefined;
101+
}
102+
103+
const directValue = getYamlValueByPath(yamlConfiguration, [name]);
104+
if (directValue !== undefined) {
105+
return toEnvironmentString(directValue);
106+
}
107+
108+
const nestedValue = getYamlValueByPath(yamlConfiguration, name.split("_"));
109+
return toEnvironmentString(nestedValue);
110+
}
111+
8112
/**
9113
* Resolves an environment variable with Docker Secrets support.
10114
* If `<name>_FILE` is set, reads the file at that path and returns its
@@ -23,7 +127,12 @@ function resolveEnv(name: string): string | undefined {
23127
);
24128
}
25129
}
26-
return process.env[name];
130+
131+
if (process.env[name] !== undefined) {
132+
return process.env[name];
133+
}
134+
135+
return resolveYamlEnvFallback(name);
27136
}
28137

29138
function parseBooleanEnvVariable(
@@ -324,6 +433,15 @@ const configuration = {
324433
} as const,
325434
} as const;
326435

436+
export const CONFIG_NAMESPACE = "gamevault";
437+
438+
export const gamevaultConfiguration = registerAs(
439+
CONFIG_NAMESPACE,
440+
() => configuration,
441+
);
442+
443+
export type AppConfiguration = typeof configuration;
444+
327445
export function getCensoredConfiguration() {
328446
const censoredConfig = JSON.parse(
329447
JSON.stringify(configuration, (_k, v) => (v === undefined ? null : v)),

src/gamevault-config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ConfigService } from "@nestjs/config";
2+
import { AppConfiguration, CONFIG_NAMESPACE } from "./configuration";
3+
4+
export const GAMEVAULT_CONFIG = Symbol("GAMEVAULT_CONFIG");
5+
6+
export function getGamevaultConfig(
7+
configService: ConfigService,
8+
): AppConfiguration {
9+
return configService.getOrThrow<AppConfiguration>(CONFIG_NAMESPACE);
10+
}

src/interceptors/http-logging.interceptor.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ describe("HttpLoggingInterceptor", () => {
1616
let mockCallHandler: any;
1717

1818
beforeEach(() => {
19-
interceptor = new HttpLoggingInterceptor();
19+
interceptor = new HttpLoggingInterceptor(
20+
jest.requireMock("../configuration").default,
21+
);
2022
mockCallHandler = {
2123
handle: jest.fn().mockReturnValue(of({ data: "response" })),
2224
};
@@ -140,7 +142,7 @@ describe("HttpLoggingInterceptor (disabled)", () => {
140142
// eslint-disable-next-line @typescript-eslint/no-require-imports
141143
const config = require("../configuration").default;
142144
config.TESTING.LOG_HTTP_TRAFFIC_ENABLED = false;
143-
interceptor = new HttpLoggingInterceptor();
145+
interceptor = new HttpLoggingInterceptor(config);
144146
mockCallHandler = {
145147
handle: jest.fn().mockReturnValue(of("result")),
146148
};

0 commit comments

Comments
 (0)