fortify-schema
Version:
TypeScript interface-like schema validation system that's easier to use than Zod
513 lines (510 loc) • 19.2 kB
JavaScript
import { ConditionalSyntaxUtils } from '../../extensions/components/ConditionalValidation/utils/SyntaxParser.js';
import { ConstraintParser } from './validators/ConstraintParser.js';
import { TypeGuards } from './validators/TypeGuards.js';
import { ValidationHelpers } from './validators/ValidationHelpers.js';
/**
* TypeScript Interface-like Schema Definition System
*
* Allows defining schemas using TypeScript-like syntax with string literals
* that feel natural and are much easier to read and write.
*/
class InterfaceSchema {
constructor(definition, options = {}) {
this.definition = definition;
this.options = options;
}
/**
* Validate data against the interface schema
*/
validate(data) {
const result = {
success: true,
errors: [],
warnings: [],
data: undefined,
};
if (typeof data !== "object" || data === null || Array.isArray(data)) {
result.success = false;
result.errors.push("Expected object");
return result;
}
const validatedData = {};
const inputKeys = Object.keys(data);
const schemaKeys = Object.keys(this.definition);
// Validate each field in the schema
for (const [key, fieldType] of Object.entries(this.definition)) {
let fieldResult;
// Handle conditional fields with full data context
if (typeof fieldType === "string" &&
ConditionalSyntaxUtils.isConditional(fieldType)) {
const conditionalConfig = ConditionalSyntaxUtils.parse(fieldType);
if (conditionalConfig) {
fieldResult = this.validateConditionalFieldWithContext(conditionalConfig, data[key], data);
}
else {
fieldResult = this.validateField(key, fieldType, data[key]);
}
}
else if (TypeGuards.isConditionalValidation(fieldType)) {
fieldResult = this.validateConditionalFieldWithContext(fieldType, data[key], data);
}
else {
fieldResult = this.validateField(key, fieldType, data[key]);
}
if (!fieldResult.success) {
result.success = false;
result.errors.push(...fieldResult.errors.map((err) => `${key}: ${err}`));
}
else if (fieldResult.data !== undefined) {
validatedData[key] = fieldResult.data;
}
result.warnings.push(...fieldResult.warnings.map((warn) => `${key}: ${warn}`));
}
// Handle extra properties - STRICT BY DEFAULT (like TypeScript)
const extraKeys = inputKeys.filter((key) => !schemaKeys.includes(key));
if (extraKeys.length > 0) {
if (this.options.allowUnknown === true) {
// Explicitly allow unknown properties
for (const key of extraKeys) {
validatedData[key] = data[key];
}
}
else {
// Strict by default - reject unknown properties
result.success = false;
result.errors.push(`Unexpected properties: ${extraKeys.join(", ")}`);
}
}
if (result.success) {
result.data = validatedData;
}
return result;
}
/**
* Validate individual field
*/
validateField(_key, fieldType, value) {
const result = {
success: true,
errors: [],
warnings: [],
data: value,
};
// Handle union values
if (TypeGuards.isUnionValue(fieldType)) {
const allowedValues = fieldType.union;
if (!allowedValues.includes(value)) {
result.success = false;
result.errors.push(`Expected one of: ${allowedValues.join(", ")}, got ${value}`);
}
return result;
}
// Handle constant values
if (TypeGuards.isConstantValue(fieldType)) {
const expectedValue = fieldType.const;
const isOptional = "optional" in fieldType && fieldType.optional;
if (value === undefined && isOptional) {
result.data = this.options.default;
return result;
}
if (value !== expectedValue) {
result.success = false;
result.errors.push(`Expected constant value ${expectedValue}, got ${value}`);
}
return result;
}
// Handle optional nested schemas
if (TypeGuards.isOptionalSchemaInterface(fieldType)) {
if (value === undefined) {
result.data = this.options.default;
return result;
}
const nestedSchema = new InterfaceSchema(fieldType.schema, this.options);
return nestedSchema.validate(value);
}
// Handle conditional validation objects
if (TypeGuards.isConditionalValidation(fieldType)) {
return this.validateConditionalField(fieldType, value);
}
// Handle nested objects
if (TypeGuards.isSchemaInterface(fieldType)) {
const nestedSchema = new InterfaceSchema(fieldType, this.options);
return nestedSchema.validate(value);
}
// Handle array of schemas
if (Array.isArray(fieldType) && fieldType.length === 1) {
if (!Array.isArray(value)) {
result.success = false;
result.errors.push("Expected array");
return result;
}
const validatedArray = [];
const itemSchema = fieldType[0];
for (let i = 0; i < value.length; i++) {
const elementResult = this.validateField(`[${i}]`, itemSchema, value[i]);
if (!elementResult.success) {
result.success = false;
result.errors.push(`Element ${i}: ${elementResult.errors.join(", ")}`);
}
else {
validatedArray.push(elementResult.data);
}
}
if (result.success) {
result.data = validatedArray;
}
return result;
}
// Handle string field types
if (typeof fieldType === "string") {
// Check if it's conditional validation syntax (both new and legacy)
if (ConditionalSyntaxUtils.isConditional(fieldType)) {
const conditionalConfig = ConditionalSyntaxUtils.parse(fieldType);
if (conditionalConfig) {
return this.validateConditionalField(conditionalConfig, value);
}
}
return this.validateStringFieldType(fieldType, value);
}
result.success = false;
result.errors.push(`Unknown field type: ${typeof fieldType}`);
return result;
}
/**
* Validate string-based field types
*/
validateStringFieldType(fieldType, value) {
const result = {
success: true,
errors: [],
warnings: [],
data: value,
};
// Parse constraints from field type
const { type: parsedType, constraints, optional: isOptional, } = ConstraintParser.parseConstraints(fieldType);
const isArray = parsedType.endsWith("[]");
const elementType = isArray ? parsedType.slice(0, -2) : parsedType;
// Handle undefined values
if (value === undefined) {
if (isOptional) {
result.data = this.options.default;
return result;
}
result.success = false;
result.errors.push("Required field is missing");
return result;
}
// Handle null values
if (value === null) {
if (isOptional) {
result.data = null;
return result;
}
result.success = false;
result.errors.push("Field cannot be null");
return result;
}
// Handle array types
if (isArray) {
if (!Array.isArray(value)) {
result.success = false;
result.errors.push("Expected array");
return result;
}
// Apply parsed constraints to options
const enhancedOptions = { ...this.options, ...constraints };
// Check array constraints
if (enhancedOptions.minItems !== undefined &&
value.length < enhancedOptions.minItems) {
result.success = false;
result.errors.push(`Array must have at least ${enhancedOptions.minItems} items`);
return result;
}
if (enhancedOptions.maxItems !== undefined &&
value.length > enhancedOptions.maxItems) {
result.success = false;
result.errors.push(`Array must have at most ${enhancedOptions.maxItems} items`);
return result;
}
const validatedArray = [];
for (let i = 0; i < value.length; i++) {
const elementResult = this.validateStringFieldType(elementType, value[i]);
if (!elementResult.success) {
result.success = false;
result.errors.push(`Element ${i}: ${elementResult.errors.join(", ")}`);
}
else {
validatedArray.push(elementResult.data);
}
}
// Check uniqueness if required
if (enhancedOptions.unique && result.success) {
const uniqueValues = new Set(validatedArray);
if (uniqueValues.size !== validatedArray.length) {
result.success = false;
result.errors.push("Array values must be unique");
}
}
if (result.success) {
result.data = validatedArray;
}
return result;
}
// Note: Conditional "when" syntax is handled at the field level, not here
// Handle constant values (e.g., "=admin", "=user")
if (elementType.startsWith("=")) {
return ValidationHelpers.validateConstantType(elementType.slice(1), value);
}
// Handle union types (e.g., "pending|accepted|rejected")
if (elementType.includes("|")) {
return ValidationHelpers.validateUnionType(elementType, value);
}
// Handle basic types - pass the original fieldType to preserve constraints
return this.validateBasicType(fieldType, value);
}
/**
* Validate basic types with enhanced constraints
*/
validateBasicType(fieldType, value) {
// Parse constraints from field type
const { type, constraints } = ConstraintParser.parseConstraints(fieldType);
// Apply parsed constraints to options
const enhancedOptions = { ...this.options, ...constraints };
// Check for Record types first
if (type.startsWith("record<") && type.endsWith(">")) {
return ValidationHelpers.validateRecordType(type, value, (fieldType, value) => this.validateStringFieldType(fieldType, value));
}
// Route to appropriate type validator
return ValidationHelpers.routeTypeValidation(type, value, enhancedOptions, constraints);
}
/**
* Validate conditional field with full data context
*/
validateConditionalFieldWithContext(conditionalDef, value, fullData) {
const result = {
success: true,
errors: [],
warnings: [],
data: value,
};
// Get the field this condition depends on
const fieldName = conditionalDef.fieldName;
const conditions = conditionalDef.conditions || [];
const defaultSchema = conditionalDef.default;
// Get the value of the dependent field
const dependentFieldValue = fullData[fieldName];
// Find the matching condition
let schemaToUse = defaultSchema;
for (const condition of conditions) {
if (this.evaluateCondition(condition, dependentFieldValue)) {
schemaToUse = condition.schema;
break;
}
}
// If we have a schema to validate against, use it
if (schemaToUse) {
if (typeof schemaToUse === "string") {
return this.validateStringFieldType(schemaToUse, value);
}
else if (typeof schemaToUse === "object") {
return this.validateField("conditional", schemaToUse, value);
}
}
// If no schema found, accept the value
return result;
}
/**
* Evaluate a condition against a field value
*/
evaluateCondition(condition, fieldValue) {
if (!condition.condition) {
return false;
}
return condition.condition(fieldValue);
}
/**
* Validate conditional field based on other field values (legacy method)
*
* Note: This method is used when conditional validation is called without
* full data context. It provides a fallback validation approach.
*/
validateConditionalField(conditionalDef, value) {
const result = {
success: true,
errors: [],
warnings: [],
data: value,
};
// Get the field this condition depends on
const conditions = conditionalDef.conditions || [];
const defaultSchema = conditionalDef.default;
// Since we don't have access to the full data object in this context,
// we'll validate against all possible schemas and accept if any pass
let validationPassed = false;
let lastError = [];
// Try to validate against each condition's schema
for (const condition of conditions) {
if (condition.schema) {
try {
const conditionResult = this.validateSchemaType(condition.schema, value);
if (conditionResult.success) {
validationPassed = true;
result.data = conditionResult.data;
result.warnings.push(...conditionResult.warnings);
break; // Found a valid schema, use it
}
else {
lastError = conditionResult.errors;
}
}
catch (error) {
// Continue to next condition if this one fails
lastError = [`Condition validation error: ${error}`];
}
}
}
// If no condition schema worked, try the default schema
if (!validationPassed && defaultSchema) {
try {
const defaultResult = this.validateSchemaType(defaultSchema, value);
if (defaultResult.success) {
validationPassed = true;
result.data = defaultResult.data;
result.warnings.push(...defaultResult.warnings);
}
else {
lastError = defaultResult.errors;
}
}
catch (error) {
lastError = [`Default schema validation error: ${error}`];
}
}
// If no schema validation passed, report the error
if (!validationPassed) {
result.success = false;
result.errors =
lastError.length > 0
? lastError
: ["No valid conditional schema found"];
result.warnings.push("Conditional validation performed without full data context");
}
return result;
}
/**
* Helper method to validate a value against a schema type
*/
validateSchemaType(schema, value) {
if (typeof schema === "string") {
return this.validateStringFieldType(schema, value);
}
else if (typeof schema === "object" && schema !== null) {
return this.validateField("conditional", schema, value);
}
else {
return ValidationHelpers.createErrorResult(`Invalid schema type: ${typeof schema}`, value);
}
}
/**
* Parse and validate (throws on error)
*/
parse(data) {
const result = this.validate(data);
if (!result.success) {
throw new SchemaValidationError(`Schema validation failed: ${result.errors.join(", ")}`, result.errors, result.warnings);
}
return result.data;
}
/**
* Safe parse (returns result object) - strictly typed input
*/
safeParse(data) {
return this.validate(data);
}
/**
* Safe parse with unknown data (for testing invalid inputs)
* Use this when you need to test data that might not match the schema
*/
safeParseUnknown(data) {
return this.validate(data);
}
/**
* Set schema options
*/
withOptions(opts) {
return new InterfaceSchema(this.definition, {
...this.options,
...opts,
});
}
/**
* Enable strict mode (no unknown properties allowed)
*/
strict() {
return this.withOptions({ strict: true });
}
/**
* Enable loose mode (allow type coercion)
*/
loose() {
return this.withOptions({ loose: true });
}
/**
* Allow unknown properties (not strict about extra fields)
* Returns a schema that accepts extra properties beyond the defined interface
*/
allowUnknown() {
return this.withOptions({ allowUnknown: true });
}
/**
* Set minimum constraints
*/
min(value) {
return this.withOptions({
min: value,
minLength: value,
minItems: value,
});
}
/**
* Set maximum constraints
*/
max(value) {
return this.withOptions({
max: value,
maxLength: value,
maxItems: value,
});
}
/**
* Require unique array values
*/
unique() {
return this.withOptions({ unique: true });
}
/**
* Set pattern for string validation
*/
pattern(regex) {
return this.withOptions({ pattern: regex });
}
/**
* Set default value
*/
default(value) {
return this.withOptions({ default: value });
}
}
/**
* Custom error class for schema validation
*/
class SchemaValidationError extends Error {
constructor(message, errors, warnings) {
super(message);
this.errors = errors;
this.warnings = warnings;
this.name = "SchemaValidationError";
}
}
export { InterfaceSchema, SchemaValidationError };
//# sourceMappingURL=InterfaceSchema.js.map