@tryloop/oats
Version:
🌾 OATS - OpenAPI TypeScript Sync. The missing link between your OpenAPI specs and TypeScript applications. Automatically watch, generate, and sync TypeScript clients from your API definitions.
350 lines • 12.8 kB
JavaScript
/**
* Configuration Schema and Validation
*
* This module provides TypeScript-based schema validation for OATS configuration,
* complementing the Joi validation with compile-time type checking.
*
* @module @oatsjs/config/schema
*/
import Joi from 'joi';
/**
* Valid API specification formats
*/
const API_SPEC_FORMATS = [
'openapi3',
'openapi2',
'swagger2',
'swagger1',
];
/**
* Valid generator types
*/
const GENERATOR_TYPES = [
'custom',
'@hey-api/openapi-ts',
'swagger-typescript-api',
'openapi-generator-cli',
];
/**
* Valid sync strategies
*/
const SYNC_STRATEGIES = ['smart', 'aggressive', 'conservative'];
/**
* Joi schema for OATS configuration
*/
export const configSchema = Joi.object({
$schema: Joi.string()
.optional()
.description('JSON Schema reference for IntelliSense'),
version: Joi.string().optional().default('1.0.0'),
services: Joi.object({
backend: Joi.object({
path: Joi.string().required().messages({
'string.empty': 'Backend path cannot be empty',
'any.required': 'Backend path is required',
}),
port: Joi.number()
.port()
.optional()
.when('runtime', {
is: 'python',
then: Joi.number().default(8000),
otherwise: Joi.number().default(4000),
}),
startCommand: Joi.string().required().messages({
'string.empty': 'Backend start command cannot be empty',
'any.required': 'Backend start command is required',
}),
readyPattern: Joi.string()
.optional()
.when('runtime', {
is: 'python',
then: Joi.string().default('Uvicorn running on'),
otherwise: Joi.string().default('Server listening on'),
}),
runtime: Joi.string().valid('node', 'python').optional().default('node'),
python: Joi.when('runtime', {
is: 'python',
then: Joi.object({
virtualEnv: Joi.string().optional(),
packageManager: Joi.string()
.valid('pip', 'poetry', 'pipenv')
.optional()
.default('pip'),
executable: Joi.string().optional().default('python'),
}).optional(),
otherwise: Joi.forbidden(),
}),
apiSpec: Joi.object({
path: Joi.string().required().messages({
'string.empty': 'API spec path cannot be empty',
'any.required': 'API spec path is required',
}),
format: Joi.string()
.valid(...API_SPEC_FORMATS)
.optional()
.default('openapi3'),
watch: Joi.array().items(Joi.string()).optional(),
}).required(),
env: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
cwd: Joi.string().optional(),
}).required(),
client: Joi.object({
path: Joi.string().required().messages({
'string.empty': 'Client path cannot be empty',
'any.required': 'Client path is required',
}),
packageName: Joi.string()
.required()
.pattern(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/)
.messages({
'string.empty': 'Package name cannot be empty',
'any.required': 'Package name is required',
'string.pattern.base': 'Package name must be a valid npm package name',
}),
generator: Joi.string()
.valid(...GENERATOR_TYPES)
.required(),
generateCommand: Joi.when('generator', {
is: 'custom',
then: Joi.string().required().messages({
'any.required': 'Generate command is required when using custom generator',
}),
otherwise: Joi.string().optional(),
}),
buildCommand: Joi.string().optional(),
linkCommand: Joi.string().optional(),
generatorConfig: Joi.object().optional(),
postGenerate: Joi.string().optional(),
autoInstall: Joi.boolean().optional().default(false),
}).required(),
frontend: Joi.object({
path: Joi.string().required().messages({
'string.empty': 'Frontend path cannot be empty',
'any.required': 'Frontend path is required',
}),
port: Joi.number().port().required().messages({
'number.base': 'Frontend port must be a number',
'number.port': 'Frontend port must be a valid port number (1-65535)',
'any.required': 'Frontend port is required - specify the exact port your dev server uses (e.g., 3000 for React, 5173 for Vite, 4200 for Angular)',
}),
startCommand: Joi.string().required().messages({
'string.empty': 'Frontend start command cannot be empty',
'any.required': 'Frontend start command is required (e.g., "npm start" for React, "npm run dev" for Vite, "ng serve" for Angular)',
}),
packageLinkCommand: Joi.string().optional(),
framework: Joi.string()
.valid('react', 'vue', 'angular', 'svelte', 'next', 'nuxt', 'auto-detect')
.optional()
.default('auto-detect'),
readyPattern: Joi.string().optional().default('compiled successfully'),
env: Joi.object().pattern(Joi.string(), Joi.string()).optional(),
}).optional(),
}).required(),
sync: Joi.object({
strategy: Joi.string()
.valid(...SYNC_STRATEGIES)
.optional()
.default('smart'),
debounceMs: Joi.number().min(0).optional().default(1000),
autoLink: Joi.boolean().optional().default(true),
notifications: Joi.boolean().optional().default(false),
retryAttempts: Joi.number().min(0).max(10).optional().default(3),
retryDelayMs: Joi.number().min(0).optional().default(2000),
runInitialGeneration: Joi.boolean().optional().default(false),
ignore: Joi.array().items(Joi.string()).optional(),
autoKillConflictingPorts: Joi.boolean().optional().default(true),
showStepDurations: Joi.boolean().optional().default(false),
pollingInterval: Joi.number().min(1000).max(60000).optional().default(5000),
skipUnlinkOnShutdown: Joi.boolean()
.optional()
.default(false)
.description('Skip unlinking packages on shutdown'),
runInstallAfterUnlink: Joi.boolean()
.optional()
.default(false)
.description('Run install command after unlinking to restore packages from registry'),
})
.optional()
.default({}),
log: Joi.object({
level: Joi.string()
.valid('debug', 'info', 'warn', 'error')
.optional()
.default('info'),
colors: Joi.boolean().optional().default(true),
timestamps: Joi.boolean().optional().default(false),
file: Joi.string().optional(),
showServiceOutput: Joi.boolean().optional().default(true).messages({
'boolean.base': 'showServiceOutput must be a boolean',
}),
quiet: Joi.boolean().optional().default(false).messages({
'boolean.base': 'quiet mode must be a boolean',
}),
})
.optional()
.default({}),
metadata: Joi.object().optional(),
});
/**
* Validates an OATS configuration object
*
* @param config - The configuration to validate
* @returns Validation result with errors and warnings
*/
export function validateConfig(config) {
const result = {
valid: true,
errors: [],
warnings: [],
};
// Joi validation
const { error, value } = configSchema.validate(config, {
abortEarly: false,
});
if (error) {
result.valid = false;
result.errors = error.details.map((detail) => ({
code: detail.type,
message: detail.message,
path: detail.path.join('.'),
value: detail.context?.value,
}));
}
// Additional custom validations
if (result.valid && value) {
const customErrors = performCustomValidations(value);
if (customErrors.length > 0) {
result.valid = false;
result.errors.push(...customErrors);
}
// Add warnings
result.warnings.push(...generateWarnings(value));
}
return result;
}
/**
* Performs custom validations that Joi cannot handle
*
* @param config - The configuration to validate
* @returns Array of validation errors
*/
function performCustomValidations(config) {
const errors = [];
// Check for circular dependencies
if (config.services.backend.path === config.services.client.path) {
errors.push({
code: 'CIRCULAR_DEPENDENCY',
message: 'Backend and client cannot share the same path',
path: 'services.client.path',
value: config.services.client.path,
});
}
// Validate custom generator command
if (config.services.client.generator === 'custom' &&
!config.services.client.generateCommand) {
errors.push({
code: 'MISSING_GENERATE_COMMAND',
message: 'Generate command is required when using custom generator',
path: 'services.client.generateCommand',
});
}
// Validate port conflicts
if (config.services.frontend &&
config.services.backend.port === config.services.frontend.port) {
errors.push({
code: 'PORT_CONFLICT',
message: 'Backend and frontend cannot use the same port',
path: 'services.frontend.port',
value: config.services.frontend.port,
});
}
return errors;
}
/**
* Generates warnings for potential issues
*
* @param config - The configuration to analyze
* @returns Array of warnings
*/
function generateWarnings(config) {
const warnings = [];
// Warn about missing frontend config
if (!config.services.frontend) {
warnings.push({
code: 'NO_FRONTEND',
message: 'No frontend configuration provided. OATS will not start a frontend service.',
path: 'services.frontend',
suggestion: 'Add frontend configuration if you want OATS to manage your frontend service',
});
}
// Warn about aggressive sync strategy
if (config.sync?.strategy === 'aggressive') {
warnings.push({
code: 'AGGRESSIVE_SYNC',
message: 'Aggressive sync strategy may cause excessive regeneration',
path: 'sync.strategy',
suggestion: 'Consider using "smart" strategy for better performance',
});
}
// Warn about low debounce time
if (config.sync?.debounceMs && config.sync.debounceMs < 500) {
warnings.push({
code: 'LOW_DEBOUNCE',
message: 'Low debounce time may cause excessive regeneration',
path: 'sync.debounceMs',
suggestion: 'Consider increasing to at least 1000ms',
});
}
// Warn about missing build command
if (!config.services.client.buildCommand) {
warnings.push({
code: 'NO_BUILD_COMMAND',
message: 'No build command specified for client',
path: 'services.client.buildCommand',
suggestion: 'Add a build command to ensure the client is properly compiled',
});
}
return warnings;
}
/**
* Default configuration values
*/
export const defaultConfig = {
version: '1.0.0',
sync: {
strategy: 'smart',
debounceMs: 1000,
autoLink: true,
notifications: false,
retryAttempts: 3,
retryDelayMs: 2000,
runInitialGeneration: false,
},
log: {
level: 'info',
colors: true,
timestamps: false,
},
};
/**
* Merges user configuration with defaults
*
* @param userConfig - User-provided configuration
* @returns Merged configuration
*/
export function mergeWithDefaults(userConfig) {
return {
...defaultConfig,
...userConfig,
sync: {
...defaultConfig.sync,
...userConfig.sync,
},
log: {
...defaultConfig.log,
...userConfig.log,
},
};
}
//# sourceMappingURL=schema.js.map