Scope: Solution JSON file validation
Status: Production
Last Updated: 2026-05-02
MOBAflow uses JSON schema-style validation to ensure that only compatible solution files can be loaded. This prevents:
❌ Corrupted JSON files
❌ Incorrect data structures
❌ Incompatible schema versions
❌ Missing required properties
Common/Validation/JsonValidationService.cs: Central validation logicDomain/Solution.cs: Schema version (SchemaVersionproperty)MOBAflow/Service/IoService.cs: Validation before deserialization in the WinUI hostTest/Common/JsonValidationTests.cs: 16+ unit tests
User opens .json file
↓
IoService.LoadAsync()
↓
File.ReadAllTextAsync()
↓
JsonValidationService.Validate()
↓
├─ Syntax check (JsonDocument.Parse)
├─ Structure check (required properties present?)
├─ Schema version check
└─ Project structure check
↓
✅ Valid → Deserialization
↓
Solution loaded
❌ Invalid → Show error
↓
User gets a clear error message
Constant: Solution.CurrentSchemaVersion = 1
{
"name": "My Model Railroad",
"schemaVersion": 1,
"projects": [
{
"name": "Main Project",
"workflows": [],
"trains": []
}
]
}- Missing
schemaVersion: Warning, but allowed (for legacy files) - Wrong version: Error, file will not be loaded
- Future versions: Auto-migration or upgrade hint (planned)
JsonDocument.Parse(json)Error examples:
❌ Invalid JSON format: Unexpected character '{' at position 42.
❌ Invalid JSON format: Expected ',' or '}' after property value.
if (root.ValueKind != JsonValueKind.Object)
return Failure("JSON root must be an object.");Error example:
❌ JSON root must be an object.
if (!root.TryGetProperty("name", out _))
return Failure("Missing required property: 'name'.");
if (!root.TryGetProperty("projects", out var projectsElement))
return Failure("Missing required property: 'projects'.");Error examples:
❌ Missing required property: 'name'.
❌ Missing required property: 'projects'.
if (projectsElement.ValueKind != JsonValueKind.Array)
return Failure("Property 'projects' must be an array.");Error example:
❌ Property 'projects' must be an array.
if (requiredSchemaVersion.HasValue)
{
if (!root.TryGetProperty("schemaVersion", out var versionElement))
return Failure(
$"Missing schema version. Expected version {requiredSchemaVersion.Value}.");
if (!versionElement.TryGetInt32(out var actualVersion))
return Failure("Schema version must be a number.");
if (actualVersion != requiredSchemaVersion.Value)
return Failure(
$"Incompatible schema version. Expected " +
$"{requiredSchemaVersion.Value}, found {actualVersion}.");
}Error examples:
❌ Missing schema version. Expected version 1.
❌ Schema version must be a number.
❌ Incompatible schema version. Expected 1, found 999.
foreach (var project in projectsElement.EnumerateArray())
{
if (project.ValueKind != JsonValueKind.Object)
return Failure($"Project at index {index} is not an object.");
if (!project.TryGetProperty("name", out _))
return Failure($"Project at index {index} is missing 'name' property.");
}Error examples:
❌ Project at index 0 is not an object.
❌ Project at index 1 is missing 'name' property.
public static JsonValidationResult Validate(
string json,
int? requiredSchemaVersion = null)Parameters:
json- Raw JSON stringrequiredSchemaVersion- Expected schema version (optional)
Return type:
public class JsonValidationResult
{
public bool IsValid { get; }
public string? ErrorMessage { get; }
}Usage:
var json = await File.ReadAllTextAsync(filePath);
var result = JsonValidationService.Validate(json, Solution.CurrentSchemaVersion);
if (!result.IsValid)
{
return (null, null, $"Invalid solution file: {result.ErrorMessage}");
}
var solution = JsonSerializer.Deserialize<Solution>(json);public async Task<(Solution? solution, string? path, string? error)> LoadAsync()
{
// ...
var json = await File.ReadAllTextAsync(result.Path);
// ✅ Validation BEFORE deserialization
var validationResult = JsonValidationService.Validate(json, Solution.CurrentSchemaVersion);
if (!validationResult.IsValid)
{
return (null, null, $"Invalid solution file: {validationResult.ErrorMessage}");
}
try
{
var sol = JsonSerializer.Deserialize<Solution>(json, JsonOptions.Default);
return (sol, result.Path, null);
}
catch (JsonException ex)
{
return (null, null, $"Failed to parse JSON: {ex.Message}");
}
}var (loadedSolution, path, error) = await _ioService.LoadAsync();
if (!string.IsNullOrEmpty(error))
{
throw new InvalidOperationException($"Failed to load solution: {error}");
}User sees:
❌ Failed to load solution: Invalid solution file: Missing required property: 'projects'
Test/Common/JsonValidationTests.cs
Validate_EmptyString_ShouldFail: Empty stringValidate_WhitespaceOnly_ShouldFail: Whitespace onlyValidate_InvalidJson_ShouldFail: Invalid JSON syntaxValidate_JsonArray_ShouldFail: Root is array instead of objectValidate_MissingNameProperty_ShouldFail: MissingnameValidate_MissingProjectsProperty_ShouldFail: MissingprojectsValidate_ProjectsNotArray_ShouldFail:projectsis not an arrayValidate_ProjectMissingName_ShouldFail: Project withoutnameValidate_ProjectNotObject_ShouldFail: Project is not an objectValidate_ValidMinimalJson_ShouldSucceed: Minimal JSON (empty)Validate_ValidJsonWithProjects_ShouldSucceed: Valid solution with projectsValidate_MissingSchemaVersion_WithRequiredVersion_ShouldFail: Schema version missingValidate_WrongSchemaVersion_ShouldFail: Wrong versionValidate_InvalidSchemaVersionType_ShouldFail: Version is string instead of numberValidate_CorrectSchemaVersion_ShouldSucceed: Correct versionValidate_NoSchemaVersionRequired_ShouldSucceed: No version required
dotnet test --filter "FullyQualifiedName~JsonValidationTests"Result:
Test summary: total: 16; failed: 0; succeeded: 16; skipped: 0
-
Update
Solution.CurrentSchemaVersion:public const int CurrentSchemaVersion = 2;
-
Migration implementieren:
public static Solution MigrateFromV1(Solution oldSolution) { // Add new fields, transform data return newSolution; }
-
In
IoService:if (solution.SchemaVersion == 1) { solution = Solution.MigrateFromV1(solution); }
-
Extend tests:
[Test] public void MigrateFromV1_ShouldConvertCorrectly() { ... }
- Always persist the schema version in new solution files
- Return clear error messages to the user
- Validate before deserialization
- Increment the version on breaking changes
- Offer migration paths for legacy files
- Do not throw generic
JsonExceptionwithout context - Do not silently ignore validation failures
- Do not deserialize unvalidated JSON strings
- Do not forget to update the schema version constant
MOBAflow's JSON validation protects against:
- ❌ Corrupted files
- ❌ Incompatible versions
- ❌ Missing required properties
- ❌ Wrong data types
Benefits:
- ✅ Better error handling
- ✅ Clear user-facing error messages
- ✅ Future-proof migration support
- ✅ High test coverage for validation
Status: Implemented & tested (16 unit tests)
Owner: Common/Validation/JsonValidationService.cs
Tests: Test/Common/JsonValidationTests.cs
The current sample solution also contains rolling-stock and display data that is not shown in the minimal examples above:
Project.Locomotives,Project.PassengerWagons, andProject.GoodsWagonsstore the vehicle libraries.Project.TrainsusesTrain.Vehiclesas the canonical, ordered, mixed consist model. Legacy split lists such as locomotive IDs and wagon IDs are not the canonical representation.Project.DisplayDevicesstores ESP32-S3 display targets, selected display model, purpose, rotation, UDP endpoint, and free-positioned layout labels.- Workflow actions use typed payload objects such as
announcement,audio,command, andtrainDestinationDisplay.
See PROJECT-REFERENCE.md for the full current data
model overview.