Files
Mods/AlinasMapMod/validation_framework.md

861 lines
27 KiB
Markdown

# Validation Framework Documentation
## Overview
The AlinasMapMod validation framework provides a standardized approach to validating spliney builder components and serialized objects. This framework uses a field-based validation system with fluent API extensions and comprehensive error reporting.
## Core Architecture
### ValidationBuilder<T> (Field-Based Validation)
The validation system is built around `ValidationBuilder<T>` which requires a field name for context:
```csharp
// Create a validation builder for a specific field
var builder = new ValidationBuilder<string>("PrefabUri");
// Add validation rules using fluent API
builder.AsUri()
.AsVanillaPrefab(allowedPrefabs);
// Validate a value
var result = builder.Validate("vanilla://locomotive_steam_small");
```
### ValidationResult System
The framework provides detailed validation feedback with manual result creation:
```csharp
public class ValidationResult
{
public bool IsValid { get; set; } = true;
public List<ValidationError> Errors { get; set; } = new List<ValidationError>();
public List<ValidationWarning> Warnings { get; set; } = new List<ValidationWarning>();
public void ThrowIfInvalid(); // Throws ValidationException if invalid
// Combines multiple validation results with null handling
public static ValidationResult Combine(params ValidationResult[] results);
}
```
### ValidationError and ValidationWarning
Detailed error information with context:
```csharp
public class ValidationError
{
public string Field { get; set; } // Field name being validated
public string Message { get; set; } // Human-readable error message
public string Code { get; set; } // Machine-readable error code
public object Value { get; set; } // The invalid value
}
public class ValidationWarning
{
public string Field { get; set; }
public string Message { get; set; }
public object Value { get; set; }
}
```
## Validation Rules
### Core Validation Rules
- **RequiredRule<T>**: Validates non-null/non-empty values with whitespace control
- **WhitelistRule<T>**: Validates values against allowed lists (includes null validation)
- **MinValueRule<T>**: Validates minimum values for comparable types
- **EnumValidationRule<TEnum>**: Validates enum values are defined
- **CustomRule<T>**: Allows custom validation logic
### URI Validation Rules
#### Basic URI Rules
- **UriFormatRule**: Validates URI format (contains "://")
- **UriSchemeValidationRule**: Validates supported schemes (case-insensitive: path, scenery, vanilla, empty)
#### Scheme-Specific Rules (Restrictive)
- **VanillaPrefabRule**: Only allows `vanilla://` URIs, validates prefab names with path extraction
- **PathUriRule**: Only allows `path://` URIs, validates scene path format
- **SceneryUriRule**: Only allows `scenery://` URIs, rejects identifiers with paths
- **EmptyUriRule**: Only allows `empty://` URIs or null/empty values
#### Comprehensive Rules (Permissive)
- **GameObjectUriRule**: Allows multiple URI schemes, validates each according to its rules
### Cache Validation Rules
- **CacheValidationRule<T>**: Validates cache key existence with null handling and exception safety
- **CacheTypeValidationRule<TExpected>**: Validates cached object types with comprehensive error handling
## Builder Standardization
### SplineyBuilderBase Class
All builders inherit from `SplineyBuilderBase` which provides standardized patterns for logging, error handling, validation, and object creation:
```csharp
public abstract class SplineyBuilderBase : StrangeCustoms.ISplineyBuilder
{
protected static readonly Serilog.ILogger Logger = Log.ForContext<SplineyBuilderBase>();
// Main build method with standardized error handling
public GameObject BuildSpliney(string id, Transform parentTransform, JObject data)
{
return SafeBuildSpliney(id, parentTransform, data, () => BuildSplineyInternal(id, parentTransform, data));
}
// To be implemented by derived classes
protected abstract GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data);
}
```
### Core Builder Features
#### Input Validation
```csharp
protected virtual void ValidateInput(string id, JObject data)
{
if (string.IsNullOrEmpty(id))
throw new ValidationException("ID cannot be null or empty");
if (data == null)
throw new ValidationException("Data cannot be null");
}
```
#### GameObject Creation
```csharp
protected virtual GameObject CreateGameObject(string id, Transform parentTransform, string objectTypeName = null)
{
var gameObject = new GameObject(id);
if (parentTransform != null)
{
gameObject.transform.SetParent(parentTransform);
}
else
{
var parent = Utils.GetParent(objectTypeName ?? "Unknown");
if (parent != null)
gameObject.transform.SetParent(parent.transform);
}
return gameObject;
}
```
#### Standardized Error Handling
Safe execution with structured logging and error handling:
```csharp
protected GameObject SafeBuildSpliney(string id, Transform parentTransform, JObject data, Func<GameObject> buildAction)
{
try
{
ValidateInput(id, data);
Logger.Information("Building {BuilderType} with ID {Id}", GetType().Name, id);
var result = buildAction();
Logger.Information("Successfully built {BuilderType} with ID {Id}", GetType().Name, id);
return result;
}
catch (ValidationException ex)
{
Logger.Error(ex, "Validation failed for {BuilderType} {Id}", GetType().Name, id);
throw new ValidationException($"Validation failed for {GetType().Name} {id}: {ex.Message}", ex);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create {BuilderType} {Id}", GetType().Name, id);
throw new InvalidOperationException($"Failed to create {GetType().Name} {id}", ex);
}
}
```
#### Transactional Building with Cleanup
For builders that need cleanup on failure:
```csharp
protected GameObject SafeBuildSplineyWithCleanup(string id, Transform parentTransform, JObject data, Func<IBuilderTransaction, GameObject> buildAction)
{
var transaction = new BuilderTransaction();
try
{
ValidateInput(id, data);
Logger.Information("Building {BuilderType} with ID {Id}", GetType().Name, id);
var result = buildAction(transaction);
transaction.Commit();
Logger.Information("Successfully built {BuilderType} with ID {Id}", GetType().Name, id);
return result;
}
catch (Exception ex)
{
transaction.Rollback();
Logger.Error(ex, "Failed to create {BuilderType} {Id}", GetType().Name, id);
throw;
}
}
```
#### Deserialization and Validation
Helper method for deserializing and validating data objects:
```csharp
protected TData DeserializeAndValidate<TData>(JObject data) where TData : IValidatable
{
var deserializedData = data.ToObject<TData>();
if (deserializedData == null)
throw new ValidationException($"Failed to deserialize data as {typeof(TData).Name}");
// Use rich validation for detailed error reporting
var validationResult = deserializedData.ValidateWithDetails();
if (!validationResult.IsValid)
{
foreach (var error in validationResult.Errors)
{
Logger.Error("Validation error in {Field}: {Message}", error.Field, error.Message);
}
}
// Log warnings but continue
foreach (var warning in validationResult.Warnings)
{
Logger.Warning("Validation warning in {Field}: {Message}", warning.Field, warning.Message);
}
validationResult.ThrowIfInvalid();
return deserializedData;
}
```
#### Component Building Pattern
For building from serialized components that implement `ICreatableComponent`:
```csharp
protected virtual GameObject BuildFromCreatableComponent<TComponent, TSerialized>(string id, JObject data)
where TComponent : Component
where TSerialized : ICreatableComponent<TComponent>, IValidatable
{
var serialized = DeserializeAndValidate<TSerialized>(data);
Logger.Information("Creating {ObjectType} {Id} using serialized component", typeof(TComponent).Name, id);
return serialized.Create(id).gameObject;
}
```
## Serialized Component Framework
### SerializedComponentBase<T>
Serialized components can inherit from `SerializedComponentBase<T>` for standardized validation functionality:
```csharp
public abstract class SerializedComponentBase<T> : ISerializedPatchableComponent<T>, IValidatable
where T : Component
{
private readonly Dictionary<string, Func<ValidationResult>> _validators = new Dictionary<string, Func<ValidationResult>>();
private bool _validationConfigured = false;
// Override to configure validation rules
protected virtual void ConfigureValidation() { }
// Abstract methods to be implemented by derived classes
public abstract T Create(string id);
public abstract void Write(T comp);
public abstract void Read(T comp);
// IValidatable implementation
public virtual void Validate() => ValidateWithDetails().ThrowIfInvalid();
public virtual ValidationResult ValidateWithDetails() { /* implementation */ }
}
```
### Property-Based Validation Rules
Use the `RuleFor` method to create validation rules for properties:
```csharp
protected ValidationBuilder<TValue> RuleFor<TValue>(Expression<Func<TValue>> propertyExpression)
{
// Creates a ValidationBuilder for the specified property
var propertyName = GetPropertyName(propertyExpression);
var builder = new ValidationBuilder<TValue>(propertyName);
// Store validator function for later execution
_validators[propertyName] = () => {
var propertyValue = GetPropertyValue<TValue>(propertyExpression);
var context = new ValidationContext { Owner = this };
return builder.Validate(propertyValue, context);
};
return builder;
}
```
### Example Implementation
```csharp
public class SerializedLoader : SerializedComponentBase<LoaderInstance>
{
public string Prefab { get; set; }
public int Capacity { get; set; }
public Vector3 Position { get; set; }
protected override void ConfigureValidation()
{
RuleFor(() => Prefab)
.Required()
.AsUri()
.AsVanillaPrefab(VanillaPrefabs.AvailableLoaderPrefabs);
RuleFor(() => Capacity)
.GreaterThan(0);
}
public override LoaderInstance Create(string id)
{
// Create and configure the component
var gameObject = new GameObject(id);
var loader = gameObject.AddComponent<LoaderInstance>();
// Apply configuration
loader.SetPrefab(Prefab);
loader.SetCapacity(Capacity);
gameObject.transform.position = Position;
return loader;
}
public override void Write(LoaderInstance comp)
{
Prefab = comp.GetPrefab();
Capacity = comp.GetCapacity();
Position = comp.transform.position;
}
public override void Read(LoaderInstance comp)
{
comp.SetPrefab(Prefab);
comp.SetCapacity(Capacity);
comp.transform.position = Position;
}
}
```
### Fluent Validation API
The framework provides a fluent API through extension methods on `ValidationBuilder<T>`:
```csharp
var builder = new ValidationBuilder<string>("PrefabField");
// Basic validation
builder.Required() // Value must not be null/empty
.AsUri(); // Must contain "://"
// URI scheme validation
builder.AsUriScheme() // Must be supported scheme
.AsVanillaPrefab(allowedPrefabs) // Specific to vanilla:// URIs
.AsPathUri() // Specific to path:// URIs
.AsSceneryUri() // Specific to scenery:// URIs
.AsEmptyUri() // Specific to empty:// URIs
.AsGameObjectUri(allowedPrefabs); // Multiple URI forms allowed
// Numeric validation
builder.GreaterThan(0) // Value > minimum
.GreaterThanOrEqual(0); // Value >= minimum
// Enum validation
builder.AsValidEnum<MyEnum>(); // Must be defined enum value
// Custom validation
builder.Custom((value, context) => { // Custom validation logic
var result = new ValidationResult { IsValid = true };
if (/* custom condition */) {
result.IsValid = false;
result.Errors.Add(new ValidationError {
Field = context.FieldName,
Message = "Custom validation failed",
Code = "CUSTOM_ERROR",
Value = value
});
}
return result;
});
```
## Complete Builder Implementation Example
### Creating a New Builder
Here's how to implement a complete builder following the standardized patterns:
```csharp
public class MyCustomBuilder : SplineyBuilderBase, IObjectFactory
{
public string Name => "My Custom Component";
public bool Enabled => true;
public Type ObjectType => typeof(MyCustomComponent);
protected override GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data)
{
// Use the standardized pattern for building from serialized components
return BuildFromCreatableComponent<MyCustomComponent, SerializedMyCustomComponent>(id, data);
}
public IEditableObject CreateObject(PatchEditor editor, string id)
{
// Create a default instance for the editor
return new SerializedMyCustomComponent
{
Prefab = "vanilla://default_prefab",
Scale = 1.0f
}.Create(id);
}
}
```
### Corresponding Serialized Component
```csharp
public class SerializedMyCustomComponent : SerializedComponentBase<MyCustomComponent>
{
public string Prefab { get; set; }
public float Scale { get; set; } = 1.0f;
public bool IsActive { get; set; } = true;
protected override void ConfigureValidation()
{
RuleFor(() => Prefab)
.Required()
.AsUri()
.AsVanillaPrefab(VanillaPrefabs.AvailableComponentPrefabs);
RuleFor(() => Scale)
.GreaterThan(0.1f);
}
public override MyCustomComponent Create(string id)
{
var gameObject = CreateGameObject(id);
var component = GetOrAddComponent<MyCustomComponent>(gameObject);
ConfigureWithActivation(gameObject, () => {
component.SetPrefab(Prefab);
component.SetScale(Scale);
component.SetActive(IsActive);
});
return component;
}
public override void Write(MyCustomComponent comp)
{
Prefab = comp.GetPrefab();
Scale = comp.GetScale();
IsActive = comp.IsActive();
}
public override void Read(MyCustomComponent comp)
{
comp.SetPrefab(Prefab);
comp.SetScale(Scale);
comp.SetActive(IsActive);
}
private GameObject CreateGameObject(string id)
{
var gameObject = new GameObject(id);
return gameObject;
}
private TComponent GetOrAddComponent<TComponent>(GameObject gameObject) where TComponent : Component
{
return gameObject.GetComponent<TComponent>() ?? gameObject.AddComponent<TComponent>();
}
private void ConfigureWithActivation(GameObject gameObject, System.Action configureAction)
{
var wasActive = gameObject.activeSelf;
gameObject.SetActive(false);
try
{
configureAction();
}
finally
{
gameObject.SetActive(wasActive);
}
}
}
```
## Migration Guide
### Updating Existing Builders
1. **Inherit from SplineyBuilderBase**: Replace custom base classes with `SplineyBuilderBase`
2. **Use BuildFromCreatableComponent**: Replace manual deserialization with the standardized pattern
3. **Implement IObjectFactory**: Add `CreateObject` method for editor integration
4. **Remove custom logging**: Use the inherited `Logger` instance
**Before:**
```csharp
public class OldBuilder : ISplineyBuilder
{
public GameObject BuildSpliney(string id, Transform parent, JObject data)
{
try
{
var serialized = data.ToObject<SerializedComponent>();
serialized.Validate(); // Simple validation
return serialized.Create(id).gameObject;
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
throw;
}
}
}
```
**After:**
```csharp
public class NewBuilder : SplineyBuilderBase, IObjectFactory
{
public string Name => "My Component";
public bool Enabled => true;
public Type ObjectType => typeof(MyComponent);
protected override GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data)
{
return BuildFromCreatableComponent<MyComponent, SerializedComponent>(id, data);
}
public IEditableObject CreateObject(PatchEditor editor, string id) =>
new SerializedComponent().Create(id);
}
```
### Updating Serialized Components
1. **Inherit from SerializedComponentBase<T>**: Replace custom validation with the standardized framework
2. **Override ConfigureValidation**: Use `RuleFor` to set up validation rules
3. **Use ValidationBuilder**: Replace manual validation with fluent API
**Before:**
```csharp
public class OldSerializedComponent : IValidatable
{
public string Prefab { get; set; }
public void Validate()
{
if (string.IsNullOrEmpty(Prefab))
throw new ValidationException("Prefab is required");
}
public ValidationResult ValidateWithDetails()
{
// Manual validation result creation
}
}
```
**After:**
```csharp
public class NewSerializedComponent : SerializedComponentBase<MyComponent>
{
public string Prefab { get; set; }
protected override void ConfigureValidation()
{
RuleFor(() => Prefab)
.Required()
.AsUri()
.AsVanillaPrefab(allowedPrefabs);
}
// Implement Create, Write, Read methods
}
```
## Builder Helper Methods
The `SplineyBuilderBase` class provides additional helper methods for common builder tasks:
### Finding Existing Components
```csharp
// Find existing components by ID using caches
protected virtual TComponent FindExistingComponent<TComponent>(string id) where TComponent : Component
{
// Uses type-specific caches for performance
if (typeof(TComponent) == typeof(PaxStationAgent))
{
StationAgentCache.Instance.TryGetValue(id, out var stationAgent);
return stationAgent as TComponent;
}
// Fallback to GameObject.FindObjectsOfType
return GameObject.FindObjectsOfType<TComponent>(true)
.FirstOrDefault(c => GetComponentId(c) == id);
}
```
### Component ID Resolution
```csharp
// Gets component ID using common patterns
protected virtual string GetComponentId(Component component)
{
// Try common identifier patterns
var identifiableProperty = component.GetType().GetProperty("identifier");
if (identifiableProperty != null)
return identifiableProperty.GetValue(component)?.ToString();
var idProperty = component.GetType().GetProperty("Id");
if (idProperty != null)
return idProperty.GetValue(component)?.ToString();
return component.name;
}
```
### Safe Component Configuration
```csharp
// Safely configure GameObject by disabling during configuration
protected virtual void ConfigureWithActivation(GameObject gameObject, System.Action configureAction)
{
var wasActive = gameObject.activeSelf;
gameObject.SetActive(false);
try
{
configureAction();
}
finally
{
gameObject.SetActive(wasActive);
}
}
```
### Validation Patterns
#### Field-Based Validation
```csharp
// Validate a URI field
var uriBuilder = new ValidationBuilder<string>("PrefabUri");
uriBuilder.Required()
.AsUri()
.AsVanillaPrefab(VanillaPrefabs.AvailableLoaderPrefabs);
var uriResult = uriBuilder.Validate("vanilla://locomotive_steam_small");
if (!uriResult.IsValid)
{
// Handle validation errors
foreach (var error in uriResult.Errors)
{
Logger.Error("Validation error in {Field}: {Message}", error.Field, error.Message);
}
}
// Validate numeric values
var numberBuilder = new ValidationBuilder<int>("Subdivisions");
numberBuilder.GreaterThan(2);
var numberResult = numberBuilder.Validate(subdivisions);
```
#### Creating Validation Rules
```csharp
// Create a custom validation rule
public class PositiveNumberRule : ValidationRule<int>
{
public override ValidationResult Validate(int value, ValidationContext context)
{
var result = new ValidationResult { IsValid = true };
if (value <= 0)
{
result.IsValid = false;
result.Errors.Add(new ValidationError
{
Field = context.FieldName,
Message = $"Value must be positive, got {value}",
Code = "MUST_BE_POSITIVE",
Value = value
});
}
return result;
}
}
// Use the custom rule
var builder = new ValidationBuilder<int>("MyNumber");
builder.AddRule(new PositiveNumberRule());
```
#### Working with ValidationContext
```csharp
var context = new ValidationContext { FieldName = "PrefabField" };
var rule = new RequiredRule<string>();
var result = rule.Validate(value, context);
// The context provides field information for error messages
```
## URI Validation Architecture
### Two Types of URI Rules
The validation framework supports two distinct approaches to URI validation:
#### Restrictive Rules (Single Scheme)
These rules only accept their specific URI scheme and reject all others:
- **VanillaPrefabRule**: Only accepts `vanilla://` URIs
- **PathUriRule**: Only accepts `path://` URIs
- **SceneryUriRule**: Only accepts `scenery://` URIs
- **EmptyUriRule**: Only accepts `empty://` URIs
```csharp
// This will ONLY accept vanilla:// URIs
var vanillaBuilder = new ValidationBuilder<string>("VanillaPrefab");
vanillaBuilder.AsVanillaPrefab(allowedPrefabs);
// VALID: "vanilla://locomotive_steam_small"
// INVALID: "path://scene/SomeObject"
```
#### Permissive Rules (Multiple Schemes)
These rules accept multiple URI schemes and validate each according to its rules:
- **GameObjectUriRule**: Accepts path://, scenery://, vanilla://, empty:// schemes
```csharp
// This will accept multiple URI forms
var gameObjectBuilder = new ValidationBuilder<string>("GameObjectUri");
gameObjectBuilder.AsGameObjectUri(allowedVanillaPrefabs);
// VALID: "vanilla://locomotive_steam_small"
// VALID: "path://scene/SomeObject"
// VALID: "scenery://tree_oak"
// VALID: "empty://"
```
### URI Parsing Details
#### Vanilla Prefab Extraction
The `VanillaPrefabRule` extracts prefab names from URIs with paths:
```csharp
// Input: "vanilla://Locomotive/part"
// Extracted prefab: "Locomotive"
// Validation: Check if "Locomotive" is in allowed prefabs list
```
#### Path URI Format
Path URIs must follow the format `path://scene/GameObject1/GameObject2/...`:
```csharp
// VALID: "path://scene/SomeGameObject"
// VALID: "path://scene/Parent/Child"
// INVALID: "path://notscene/SomeObject"
```
#### Scenery URI Format
Scenery URIs must be simple identifiers without paths:
```csharp
// VALID: "scenery://tree_oak"
// INVALID: "scenery://category/tree_oak" // Contains path separator
```
### Case Sensitivity
URI scheme validation is case-insensitive:
```csharp
// All of these are equivalent:
// "PATH://scene/Object"
// "path://scene/Object"
// "Path://scene/Object"
```
## Common Error Codes
The validation framework uses standardized error codes for consistent error handling:
### Core Validation Errors
- `REQUIRED`: Value is required but null/empty
- `REQUIRED_NOT_WHITESPACE`: Value cannot be whitespace-only
- `INVALID_VALUE`: Value not in allowed whitelist
- `MIN_VALUE_VIOLATION`: Value below minimum threshold
- `INVALID_ENUM_VALUE`: Enum value not defined
### URI Validation Errors
- `INVALID_URI_FORMAT`: URI missing "://" separator
- `INVALID_URI_SCHEME`: Unsupported URI scheme
- `NON_VANILLA_URI_NOT_ALLOWED`: Non-vanilla URI in vanilla-only context
- `NON_PATH_URI_NOT_ALLOWED`: Non-path URI in path-only context
- `NON_SCENERY_URI_NOT_ALLOWED`: Non-scenery URI in scenery-only context
- `NON_EMPTY_URI_NOT_ALLOWED`: Non-empty URI in empty-only context
- `INVALID_VANILLA_PREFAB`: Vanilla prefab not in allowed list
- `INVALID_PATH_URI_FORMAT`: Path URI format incorrect
- `MISSING_PATH_COMPONENTS`: Path URI missing GameObject path
- `MISSING_SCENERY_IDENTIFIER`: Scenery URI missing identifier
- `SCENERY_IDENTIFIER_CONTAINS_PATH`: Scenery identifier contains path separators
- `VALUE_MUST_BE_EMPTY`: Value must be empty or null
### Cache Validation Errors
- `CACHE_KEY_NULL_OR_EMPTY`: Cache key cannot be null or empty
- `CACHE_NOT_FOUND`: Cache key not found
- `CACHE_ACCESS_ERROR`: Error accessing cache
- `CACHE_OBJECT_NULL`: Cached object is null
- `INVALID_CACHE_OBJECT_TYPE`: Cached object has wrong type
## Benefits
1. **Field-Based Context**: Validation tied to specific fields provides clear error context
2. **Comprehensive Error Reporting**: Detailed error messages with codes, fields, and values
3. **Flexible Architecture**: Support for both restrictive and permissive validation patterns
4. **URI Scheme Awareness**: Intelligent handling of different URI schemes and formats
5. **Exception Safety**: Proper null handling and exception management
6. **Extensibility**: Easy to add new validation rules through inheritance
7. **Performance**: Efficient validation with early returns and minimal overhead
8. **Testability**: Comprehensive test coverage ensures reliability
## Migration Notes
When updating validation code:
1. **ValidationBuilder Constructor**: Always provide a field name
2. **Manual ValidationResult Creation**: No static `Valid`/`Invalid` properties
3. **URI Rule Selection**: Choose restrictive vs permissive rules based on requirements
4. **Null Handling**: Validation rules now properly handle null values
5. **Error Codes**: Use standardized error codes for consistent error handling
## Best Practices
1. **Use Descriptive Field Names**: `"PrefabUri"` instead of `"field1"`
2. **Chain Validation Rules**: Use fluent API for multiple validation rules
3. **Handle ValidationResult**: Always check `IsValid` before proceeding
4. **Log Validation Errors**: Use structured logging with error context
5. **Choose Appropriate URI Rules**: Restrictive for specific schemes, permissive for flexibility
6. **Test Validation Logic**: Write tests for both valid and invalid cases