import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import yaml from 'yaml';
import { configSchema, type Config, type PartialConfig } from './schema.js';
import { log } from '../utils/logger.js';
/**
* CLI arguments that map to config
*/
export interface CLIArgs {
specUrl?: string;
specFile?: string;
format?: string;
upstreamUrl?: string;
port?: number;
host?: string;
config?: string;
logLevel?: string;
logFormat?: string;
includeTools?: string;
excludeTools?: string;
excludeMethods?: string;
includeTags?: string;
toolWarning?: boolean;
}
/**
* Environment variables that map to config
*/
const ENV_MAPPING: Record<string, string> = {
SPEC_URL: 'spec.url',
SPEC_FILE: 'spec.file',
SPEC_FORMAT: 'format',
UPSTREAM_URL: 'upstream.baseUrl',
PORT: 'server.port',
HOST: 'server.host',
LOG_LEVEL: 'logging.level',
LOG_FORMAT: 'logging.format',
AUTH_TYPE: 'auth.type',
AUTH_NAME: 'auth.name',
AUTH_VALUE: 'auth.value',
// Tool filtering
TOOLS_INCLUDE: 'tools.include',
TOOLS_EXCLUDE: 'tools.exclude',
METHODS_EXCLUDE: 'tools.autoDisable.methods',
TAGS_INCLUDE: 'tools.includeTags',
TOOL_WARNING: 'tools.warnings.enabled',
};
/**
* Set a nested value in an object using dot notation path
*/
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
/**
* Interpolate environment variables in string values
* Supports ${VAR_NAME} syntax
*/
function interpolateEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
const envValue = process.env[envVar];
if (envValue === undefined) {
log.warn(`Environment variable ${envVar} not found, using empty string`);
return '';
}
return envValue;
});
}
/**
* Recursively interpolate env vars in an object
*/
function interpolateConfig(obj: unknown): unknown {
if (typeof obj === 'string') {
return interpolateEnvVars(obj);
}
if (Array.isArray(obj)) {
return obj.map(interpolateConfig);
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = interpolateConfig(value);
}
return result;
}
return obj;
}
/**
* Load config from a YAML/JSON file
*/
async function loadConfigFile(filePath: string): Promise<PartialConfig> {
if (!existsSync(filePath)) {
throw new Error(`Config file not found: ${filePath}`);
}
const content = await readFile(filePath, 'utf-8');
// Try YAML first (also handles JSON)
try {
const parsed = yaml.parse(content) as PartialConfig;
return parsed;
} catch {
throw new Error(`Failed to parse config file: ${filePath}`);
}
}
/**
* ENV vars that should be parsed as comma-separated arrays
*/
const ARRAY_ENV_VARS = new Set([
'TOOLS_INCLUDE',
'TOOLS_EXCLUDE',
'METHODS_EXCLUDE',
'TAGS_INCLUDE',
]);
/**
* Load config from environment variables
*/
function loadEnvConfig(): PartialConfig {
const config: Record<string, unknown> = {};
for (const [envVar, path] of Object.entries(ENV_MAPPING)) {
const value = process.env[envVar];
if (value !== undefined) {
let finalValue: unknown = value;
// Convert numeric values
if (envVar === 'PORT') {
const numValue = Number(value);
if (!isNaN(numValue)) {
finalValue = numValue;
}
}
// Convert boolean values
else if (envVar === 'TOOL_WARNING') {
finalValue = value.toLowerCase() !== 'false' && value !== '0';
}
// Convert comma-separated values to arrays
else if (ARRAY_ENV_VARS.has(envVar)) {
finalValue = value.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
}
setNestedValue(config, path, finalValue);
}
}
return config as PartialConfig;
}
/**
* Convert CLI args to config
*/
function cliArgsToConfig(args: CLIArgs): PartialConfig {
const config: PartialConfig = {
spec: {},
upstream: {} as PartialConfig['upstream'],
server: {},
logging: {},
tools: {},
};
if (args.specUrl) {
config.spec.url = args.specUrl;
}
if (args.specFile) {
config.spec.file = args.specFile;
}
if (args.format) {
config.format = args.format as 'openapi' | 'postman';
}
if (args.upstreamUrl) {
config.upstream = { baseUrl: args.upstreamUrl };
}
if (args.port) {
config.server!.port = args.port;
}
if (args.host) {
config.server!.host = args.host;
}
if (args.logLevel) {
config.logging!.level = args.logLevel as 'debug' | 'info' | 'warn' | 'error';
}
if (args.logFormat) {
config.logging!.format = args.logFormat as 'json' | 'pretty';
}
// Tool filtering from CLI
if (args.includeTools) {
config.tools!.include = args.includeTools.split(',').map((s) => s.trim());
}
if (args.excludeTools) {
config.tools!.exclude = args.excludeTools.split(',').map((s) => s.trim());
}
if (args.excludeMethods) {
config.tools!.autoDisable = {
methods: args.excludeMethods.split(',').map((s) => s.trim().toUpperCase()) as Array<'DELETE' | 'PUT' | 'PATCH' | 'POST'>,
};
}
if (args.includeTags) {
config.tools!.includeTags = args.includeTags.split(',').map((s) => s.trim());
}
if (args.toolWarning === false) {
config.tools!.warnings = { enabled: false };
}
return config;
}
/**
* Deep merge objects (later values override earlier)
*/
function deepMerge<T extends Record<string, unknown>>(...objects: Partial<T>[]): T {
const result: Record<string, unknown> = {};
for (const obj of objects) {
for (const [key, value] of Object.entries(obj)) {
if (value === undefined) continue;
if (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
typeof result[key] === 'object' &&
result[key] !== null
) {
result[key] = deepMerge(result[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
result[key] = value;
}
}
}
return result as T;
}
/**
* Load and merge configuration from all sources
* Priority: CLI args > ENV vars > config file > defaults
*/
export async function loadConfig(args: CLIArgs): Promise<Config> {
const configs: PartialConfig[] = [];
// 1. Load config file if specified
if (args.config) {
const fileConfig = await loadConfigFile(args.config);
configs.push(fileConfig);
log.debug('Loaded config from file', { file: args.config });
}
// 2. Load from environment
const envConfig = loadEnvConfig();
configs.push(envConfig);
// 3. Convert CLI args
const cliConfig = cliArgsToConfig(args);
configs.push(cliConfig);
// 4. Merge all configs
const merged = deepMerge(...configs);
// 5. Interpolate environment variables
const interpolated = interpolateConfig(merged) as PartialConfig;
// 6. Validate with Zod
const result = configSchema.safeParse(interpolated);
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
throw new Error(`Configuration validation failed:\n${errors.join('\n')}`);
}
return result.data;
}
/**
* Validate a partial config
*/
export function validateConfig(config: unknown): Config {
const result = configSchema.safeParse(config);
if (!result.success) {
const errors = result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
throw new Error(`Configuration validation failed:\n${errors.join('\n')}`);
}
return result.data;
}