Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="xunit.v3" Version="4.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
Expand Down
58 changes: 58 additions & 0 deletions src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Globalization;
using FluentCMS.Schema;

namespace FluentCMS.Core.Content.Validation;

public static class ConditionEvaluator
{
public static bool Evaluate(Condition condition, IReadOnlyDictionary<string, object?> values)
{
ArgumentNullException.ThrowIfNull(condition);
ArgumentNullException.ThrowIfNull(values);

switch (condition.Operator)
{
case ConditionOperator.Equal:
return Matches(Get(values, condition), condition.Values[0]);
case ConditionOperator.In:
var actual = Get(values, condition);
return condition.Values.Any(expected => Matches(actual, expected));
case ConditionOperator.IsEmpty:
return FieldValue.IsEmpty(Get(values, condition));
case ConditionOperator.And:
return condition.Operands.All(operand => Evaluate(operand, values));
case ConditionOperator.Or:
return condition.Operands.Any(operand => Evaluate(operand, values));
case ConditionOperator.Not:
return !Evaluate(condition.Operands[0], values);
default:
throw new ArgumentException($"Unknown condition operator {condition.Operator}.", nameof(condition));
}
}

// An unknown field is null.
private static object? Get(IReadOnlyDictionary<string, object?> values, Condition condition) =>
condition.FieldName is { } name && values.TryGetValue(name, out var value) ? value : null;

// A list matches when one of its items does (a multi select "equals" a value it contains), and an empty list matches null.
private static bool Matches(object? actual, object? expected)
{
if (FieldValue.Items(actual) is { } items)
{
var list = items.ToList();
return expected is null ? list.Count == 0 : list.Any(item => Same(FieldValue.Normalize(item), expected));
}

return Same(FieldValue.Normalize(actual), expected);
}

private static bool Same(object? actual, object? expected) => (actual, expected) switch
{
(null, null) => true,
(null, _) or (_, null) => false,
(string left, string right) => string.Equals(left, right, StringComparison.Ordinal),
(bool left, bool right) => left == right,
(long or decimal, long or decimal) => Convert.ToDecimal(actual, CultureInfo.InvariantCulture) == Convert.ToDecimal(expected, CultureInfo.InvariantCulture),
_ => false,
};
}
53 changes: 53 additions & 0 deletions src/FluentCMS.Core/Content/Validation/ConditionJson.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Text;
using System.Text.Json;
using FluentCMS.Core.Schema;
using FluentCMS.Schema;

namespace FluentCMS.Core.Content.Validation;

// A condition in the JSON shape schema.json holds, by way of the schema reader and writer, so there is one format.
public static class ConditionJson
{
public static void Write(Condition condition, Utf8JsonWriter writer)
{
ArgumentNullException.ThrowIfNull(condition);
ArgumentNullException.ThrowIfNull(writer);
SchemaJsonWriter.WriteCondition(writer, condition);
}

public static string ToJson(Condition condition)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
Write(condition, writer);
}

return Encoding.UTF8.GetString(stream.ToArray());
}

public static Condition Read(JsonElement element)
{
// The reader reports problems on a builder; one without types fails its build with exactly those problems.
var builder = new SchemaBuilder();
var condition = new SchemaJsonReader(builder).ReadCondition(element, "condition");

try
{
builder.Build();
}
catch (SchemaValidationException exception)
{
throw new JsonException($"The condition is not valid: {string.Join(" ", exception.Errors.Select(error => error.Message.TrimStart(':', ' ')))}");
}

return condition ?? throw new JsonException("The condition is not valid.");
}

public static Condition Parse(string json)
{
ArgumentNullException.ThrowIfNull(json);
using var document = JsonDocument.Parse(json);
return Read(document.RootElement);
}
}
264 changes: 264 additions & 0 deletions src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.RegularExpressions;
using FluentCMS.Content;
using FluentCMS.Schema;

namespace FluentCMS.Core.Content.Validation;

// A field that was checked, so the custom field validators see exactly the fields the declarative rules saw.
internal sealed record FieldVisit(SchemaType Owner, SchemaField Field, string Path, object? Value, IReadOnlyDictionary<string, object?> Siblings);

// Required, RequiredWhen, length, range, pattern and select options, for every visible field, recursing into
// components and blocks. A block or component instance is a dictionary keyed by API field name (a block carries
// its block name under "type") or a CLR object whose public properties are the fields.
internal sealed class DeclarativeValidator(SchemaModel model)
{
private const string TypeKey = "type";
private const int MaxNestingDepth = 8;
private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);

private readonly ConcurrentDictionary<SchemaField, Regex> _patterns = new();

public void Validate(SchemaType type, IReadOnlyDictionary<string, object?> values, ValidationContext context, List<FieldVisit> visits) =>
ValidateFields(type, values, "", context, visits, 0);

private void ValidateFields(SchemaType type, IReadOnlyDictionary<string, object?> values, string prefix, ValidationContext context, List<FieldVisit> visits, int depth)
{
foreach (var field in type.Fields)
{
// A hidden field is not part of the entry, whatever it holds.
if (field.VisibleWhen is not null && !ConditionEvaluator.Evaluate(field.VisibleWhen, values))
{
continue;
}

values.TryGetValue(field.Name, out var value);
var path = prefix + field.Name;
visits.Add(new FieldVisit(type, field, path, value, values));

if (FieldValue.IsEmpty(value))
{
CheckRequired(field, path, values, context);
continue;
}

CheckLength(field, path, value, context);
CheckItems(field, path, value, context);

if (depth < MaxNestingDepth)
{
Recurse(field, path, value, context, visits, depth + 1);
}
}
}

private static void CheckRequired(SchemaField field, string path, IReadOnlyDictionary<string, object?> values, ValidationContext context)
{
if (context.Mode != ValidationMode.Publish)
{
return;
}

if (field.Required)
{
context.AddError(path, ValidationErrorCodes.Required, "A value is required.");
}
else if (field.RequiredWhen is not null && ConditionEvaluator.Evaluate(field.RequiredWhen, values))
{
context.AddError(path, ValidationErrorCodes.RequiredByCondition, $"A value is required when {field.RequiredWhen}.");
}
}

// A text is limited in characters; a multiple field, and blocks, in items.
private static void CheckLength(SchemaField field, string path, object? value, ValidationContext context)
{
int length;
string unit;
if (IsList(field) && FieldValue.Items(value) is { } items)
{
length = items.Count();
unit = "items";
}
else if (!field.Multiple && value is string text)
{
length = text.Length;
unit = "characters";
}
else
{
return;
}

if (field.MinLength is { } min && length < min)
{
context.AddError(path, ValidationErrorCodes.MinLength, $"Must have at least {min} {unit}, has {length}.");
}

if (field.MaxLength is { } max && length > max)
{
context.AddError(path, ValidationErrorCodes.MaxLength, $"Must have at most {max} {unit}, has {length}.");
}
}

// The rules on the value itself, applied to each item of a multiple field at its own path (tags[1]).
private void CheckItems(SchemaField field, string path, object? value, ValidationContext context)
{
if (field.FieldType is ValidatedFieldTypes.Component or ValidatedFieldTypes.Blocks)
{
return;
}

if (field.Multiple && FieldValue.Items(value) is { } items)
{
var index = 0;
foreach (var item in items)
{
CheckValue(field, $"{path}[{index++}]", item, context);
}
}
else if (!field.Multiple)
{
CheckValue(field, path, value, context);
}
}

private void CheckValue(SchemaField field, string path, object? value, ValidationContext context)
{
if (FieldValue.IsEmpty(value))
{
return;
}

if (RangeValue(value) is { } number)
{
if (field.Minimum is { } min && number < min)
{
context.AddError(path, ValidationErrorCodes.BelowMinimum, $"Must be at least {Format(min)}.");
}

if (field.Maximum is { } max && number > max)
{
context.AddError(path, ValidationErrorCodes.AboveMaximum, $"Must be at most {Format(max)}.");
}
}

if (field.Pattern is not null && value is string text && !Matches(field, text))
{
context.AddError(path, ValidationErrorCodes.PatternMismatch, $"Does not match the pattern {field.Pattern}.");
}

if (field.FieldType == ValidatedFieldTypes.Select
&& field.Settings.TryGetValue(FieldSettings.Options, out var setting)
&& setting is IEnumerable<string> options
&& FieldValue.Normalize(value) is { } option
&& !options.Contains(option as string, StringComparer.Ordinal))
{
context.AddError(path, ValidationErrorCodes.NotAnAllowedOption, $"{option} is not one of: {string.Join(", ", options)}.");
}
}

private void Recurse(SchemaField field, string path, object? value, ValidationContext context, List<FieldVisit> visits, int depth)
{
if (field.FieldType == ValidatedFieldTypes.Component
&& field.Settings.TryGetValue(FieldSettings.Component, out var name)
&& model.FindComponent((string)name) is { } component)
{
if (field.Multiple)
{
var index = 0;
foreach (var item in FieldValue.Items(value) ?? [])
{
ValidateInstance(component, item, $"{path}[{index++}].", context, visits, depth);
}
}
else
{
ValidateInstance(component, value, $"{path}.", context, visits, depth);
}
}
else if (field.FieldType == ValidatedFieldTypes.Blocks)
{
var allowed = field.Settings.TryGetValue(FieldSettings.Blocks, out var setting) && setting is IEnumerable<string> names ? names.ToList() : [];
var index = 0;
foreach (var item in FieldValue.Items(value) ?? [])
{
var itemPath = $"{path}[{index++}]";
var block = FindBlock(item);
if (block is null || !allowed.Contains(block.Name))
{
context.AddError(itemPath, ValidationErrorCodes.NotAnAllowedOption, $"The block type {BlockName(item) ?? "(none)"} is not one of: {string.Join(", ", allowed)}.");
continue;
}

ValidateInstance(block, item, $"{itemPath}.", context, visits, depth);
}
}
}

private void ValidateInstance(SchemaType type, object? instance, string prefix, ValidationContext context, List<FieldVisit> visits, int depth)
{
if (FieldValue.ReadInstance(instance) is { } values)
{
ValidateFields(type, values, prefix, context, visits, depth);
}
}

private SchemaType? FindBlock(object? item) => BlockName(item) is { } name ? model.FindBlock(name) : null;

private string? BlockName(object? item)
{
if (item is IReadOnlyDictionary<string, object?> dictionary)
{
return dictionary.TryGetValue(TypeKey, out var name) ? name as string : null;
}

if (item is IDictionary<string, object?> mutable)
{
return mutable.TryGetValue(TypeKey, out var name) ? name as string : null;
}

return item is null ? null : model.Blocks.FirstOrDefault(block => block.ClrType == item.GetType())?.Name;
}

private bool Matches(SchemaField field, string text)
{
var regex = _patterns.GetOrAdd(field, static f => new Regex(f.Pattern!, RegexOptions.CultureInvariant, MatchTimeout));
try
{
return regex.IsMatch(text);
}
catch (RegexMatchTimeoutException)
{
return false;
}
}

private static bool IsList(SchemaField field) => field.Multiple || field.FieldType == ValidatedFieldTypes.Blocks;

// Only numbers have a range; the model does not say whether a bound on a date means days or seconds.
// A float beyond what a decimal holds counts as the nearest decimal bound, so it is out of range in its direction.
private static decimal? RangeValue(object? value) => value switch
{
byte or sbyte or short or ushort or int or uint or long or ulong or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture),
float or double => Clamp(Convert.ToDouble(value, CultureInfo.InvariantCulture)),
_ => null,
};

private static decimal? Clamp(double number) => number switch
{
double.NaN => null,
>= (double)decimal.MaxValue => decimal.MaxValue,
<= (double)decimal.MinValue => decimal.MinValue,
_ => (decimal)number,
};

private static string Format(decimal number) => number.ToString("G29", CultureInfo.InvariantCulture);
}

internal static class ValidatedFieldTypes
{
public const string Select = "select";
public const string Component = "component";
public const string Blocks = "blocks";
}
Loading
Loading