chore(config): centralize feature settings
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
optionalEnvironmentValue,
|
||||
readFeatureEnvironment,
|
||||
requiredEnvironmentUrl,
|
||||
requiredEnvironmentValue,
|
||||
} from "@/lib/config/environment";
|
||||
|
||||
describe("feature environment configuration", () => {
|
||||
test("returns trimmed required and optional values", () => {
|
||||
const environment = { REQUIRED: " value ", OPTIONAL: " optional " };
|
||||
expect(requiredEnvironmentValue("REQUIRED", environment)).toBe("value");
|
||||
expect(optionalEnvironmentValue("OPTIONAL", environment)).toBe("optional");
|
||||
expect(optionalEnvironmentValue("MISSING", environment)).toBeNull();
|
||||
});
|
||||
|
||||
test("reports the feature and missing variable", () => {
|
||||
expect(() =>
|
||||
readFeatureEnvironment("Discord", ["TOKEN", "GUILD"] as const, {
|
||||
TOKEN: "token",
|
||||
}),
|
||||
).toThrow("Discord is not configured: Missing required environment variable: GUILD");
|
||||
});
|
||||
|
||||
test("accepts absolute URLs and removes a trailing slash", () => {
|
||||
expect(requiredEnvironmentUrl("BASE_URL", { BASE_URL: "https://example.com/" }))
|
||||
.toBe("https://example.com");
|
||||
expect(() => requiredEnvironmentUrl("BASE_URL", { BASE_URL: "/relative" }))
|
||||
.toThrow("must be an absolute URL");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const urlString = z.url();
|
||||
|
||||
export type Environment = Record<string, string | undefined>;
|
||||
|
||||
export function requiredEnvironmentValue(
|
||||
name: string,
|
||||
environment: Environment = process.env,
|
||||
) {
|
||||
const parsed = nonEmptyString.safeParse(environment[name]);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function optionalEnvironmentValue(
|
||||
name: string,
|
||||
environment: Environment = process.env,
|
||||
) {
|
||||
const parsed = nonEmptyString.safeParse(environment[name]);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function requiredEnvironmentUrl(
|
||||
name: string,
|
||||
environment: Environment = process.env,
|
||||
) {
|
||||
const value = requiredEnvironmentValue(name, environment);
|
||||
const parsed = urlString.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Environment variable ${name} must be an absolute URL`);
|
||||
}
|
||||
return parsed.data.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function readFeatureEnvironment<Names extends readonly string[]>(
|
||||
feature: string,
|
||||
names: Names,
|
||||
environment: Environment = process.env,
|
||||
): { [Name in Names[number]]: string } {
|
||||
try {
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [name, requiredEnvironmentValue(name, environment)]),
|
||||
) as { [Name in Names[number]]: string };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid configuration";
|
||||
throw new Error(`${feature} is not configured: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
optionalEnvironmentValue,
|
||||
readFeatureEnvironment,
|
||||
requiredEnvironmentUrl,
|
||||
} from "@/lib/config/environment";
|
||||
|
||||
export function getCanonicalUrl() {
|
||||
if (optionalEnvironmentValue("BASE_URL")) {
|
||||
return requiredEnvironmentUrl("BASE_URL");
|
||||
}
|
||||
return requiredEnvironmentUrl("NEXTAUTH_URL");
|
||||
}
|
||||
|
||||
export function getDiscordServerConfig() {
|
||||
const config = readFeatureEnvironment("Discord", [
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"DISCORD_GUILD_ID",
|
||||
] as const);
|
||||
return {
|
||||
token: config.DISCORD_BOT_TOKEN,
|
||||
guildId: config.DISCORD_GUILD_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export function getYoutubeFollowerConfig() {
|
||||
const config = readFeatureEnvironment("YouTube follower counts", [
|
||||
"YOUTUBE_API_KEY",
|
||||
"YOUTUBE_CHANNEL_ID",
|
||||
] as const);
|
||||
return { apiKey: config.YOUTUBE_API_KEY, channelId: config.YOUTUBE_CHANNEL_ID };
|
||||
}
|
||||
|
||||
export function getRobloxFollowerConfig() {
|
||||
return {
|
||||
userId: readFeatureEnvironment("Roblox follower counts", [
|
||||
"ROBLOX_USER_ID",
|
||||
] as const).ROBLOX_USER_ID,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTiktokApiConfig() {
|
||||
const accessToken = optionalEnvironmentValue("TIKTOK_ACCESS_TOKEN");
|
||||
if (accessToken) {
|
||||
return {
|
||||
clientKey: null,
|
||||
clientSecret: null,
|
||||
refreshToken: null,
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
const config = readFeatureEnvironment("TikTok", [
|
||||
"TIKTOK_CLIENT_KEY",
|
||||
"TIKTOK_CLIENT_SECRET",
|
||||
"TIKTOK_REFRESH_TOKEN",
|
||||
] as const);
|
||||
return {
|
||||
clientKey: config.TIKTOK_CLIENT_KEY,
|
||||
clientSecret: config.TIKTOK_CLIENT_SECRET,
|
||||
refreshToken: config.TIKTOK_REFRESH_TOKEN,
|
||||
accessToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getYoutubePublishingConfig() {
|
||||
const config = readFeatureEnvironment("YouTube publishing", [
|
||||
"YOUTUBE_CLIENT_ID",
|
||||
"YOUTUBE_CLIENT_SECRET",
|
||||
"YOUTUBE_REFRESH_TOKEN",
|
||||
] as const);
|
||||
return {
|
||||
clientId: config.YOUTUBE_CLIENT_ID,
|
||||
clientSecret: config.YOUTUBE_CLIENT_SECRET,
|
||||
refreshToken: config.YOUTUBE_REFRESH_TOKEN,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user