Skip to content

Commit

Permalink
Start work on new validation
Browse files Browse the repository at this point in the history
  • Loading branch information
ivarne committed Dec 7, 2023
1 parent a088662 commit 07726d6
Show file tree
Hide file tree
Showing 6 changed files with 440 additions and 0 deletions.
21 changes: 21 additions & 0 deletions src/Altinn.App.Core/Features/IValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Collections.Specialized;
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;

namespace Altinn.App.Core.Features;

/// <summary>
/// Core interface for validation of instances. Only a single implementation of this interface should exist in the app.
/// </summary>
public interface IValidationService
{
/// <summary>
/// Validates the instance with all data elements on the current task
/// </summary>
Task<List<ValidationIssue>> ValidateTask(Instance instance);

/// <summary>
/// Validates a single data element
/// </summary>
Task<Dictionary<string,List<ValidationIssue>>> ValidateDataElement(Instance instance, DataElement dataType, object data, List<string>? changedFields = null);
}
108 changes: 108 additions & 0 deletions src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Linq.Expressions;
using Altinn.App.Core.Helpers;
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;

namespace Altinn.App.Core.Features.Validation;

/// <summary>
/// Simple wrapper for validation of form data that does the type checking for you.
/// </summary>
/// <typeparam name="TModel">The type of the model this class will validate</typeparam>
public abstract class GenericFormDataValidator<TModel> : IFormDataValidator

Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

The type or namespace name 'IFormDataValidator' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

The type or namespace name 'IFormDataValidator' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs

View workflow job for this annotation

GitHub Actions / Static code analysis

The type or namespace name 'IFormDataValidator' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs

View workflow job for this annotation

GitHub Actions / Run dotnet build and test (ubuntu-latest)

The type or namespace name 'IFormDataValidator' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs

View workflow job for this annotation

GitHub Actions / Run dotnet build and test (ubuntu-latest)

The type or namespace name 'IFormDataValidator' could not be found (are you missing a using directive or an assembly reference?)
{
/// <summary>
/// Constructor to force the DataType to be set.
/// </summary>
/// <param name="dataType"></param>
protected GenericFormDataValidator(string dataType)
{
DataType = dataType;
}
/// <inheritdoc />
public string DataType { get; private init; }

private readonly List<string> _runForPrefixes = new List<string>();
// ReSharper disable once StaticMemberInGenericType
private static readonly AsyncLocal<List<ValidationIssue>> ValidationIssues = new();

/// <summary>
/// Default implementation that respects the runFor prefixes.
/// </summary>
public bool ShouldRun(List<string>? changedFields = null)
{
if (changedFields == null)
{
return true;
}

if (_runForPrefixes.Count == 0)
{
return true;
}

foreach (var prefix in _runForPrefixes)
{
foreach (var changedField in changedFields)
{
if (IsMatch(changedField, prefix))
{
return true;
}
}
}

return false;
}

private static bool IsMatch(string changedField, string prefix)
{
return changedField.StartsWith(prefix) || prefix.StartsWith(changedField);
}

/// <summary>
/// Easy way to configure <see cref="ShouldRun"/> to only run for fields that start with the given prefix.
/// </summary>
/// <param name="selector"></param>
/// <typeparam name="T1"></typeparam>
protected void RunFor<T1>(Expression<Func<TModel, T1>> selector)
{
_runForPrefixes.Add(LinqExpressionHelpers.GetJsonPath(selector));
}

protected void CreateValidationIssue<T>(Expression<Func<TModel,T>> selector, string textKey, ValidationIssueSeverity severity = ValidationIssueSeverity.Error)
{
Debug.Assert(ValidationIssues.Value is not null);
ValidationIssues.Value.Add( new ValidationIssue
{
Field = LinqExpressionHelpers.GetJsonPath(selector),
CustomTextKey = textKey,
Severity = severity
});
}

protected void AddValidationIssue(ValidationIssue issue)
{
Debug.Assert(ValidationIssues.Value is not null);
ValidationIssues.Value.Add(issue);
}

public async Task<List<ValidationIssue>> ValidateFormData(Instance instance, DataElement dataElement, object data, List<string>? changedFields = null)
{
if (data is not TModel model)
{
throw new ArgumentException($"Data is not of type {typeof(TModel)}");
}

ValidationIssues.Value = new List<ValidationIssue>();;
await ValidateFormData(instance, dataElement, model);
return ValidationIssues.Value;

}

/// <summary>
/// Implement this method to validate the data.
/// </summary>
protected abstract Task ValidateFormData(Instance instance, DataElement dataElement, TModel data, List<string>? changedFields = null);
}
82 changes: 82 additions & 0 deletions src/Altinn.App.Core/Helpers/LinqExpressionHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json.Serialization;

namespace Altinn.App.Core.Helpers;

/// <summary>
/// Utilities for working with <see cref="Expression"/>
/// </summary>
public static class LinqExpressionHelpers
{
/// <summary>
/// Gets the JSON path from an expression
/// </summary>
/// <param name="expression">The expression</param>
/// <returns>The JSON path</returns>
public static string GetJsonPath<TModel, T>(Expression<Func<TModel, T>> expression)
{
return GetJsonPath_internal(expression);
}

/// <summary>
/// Need a private method to avoid the generic type parameter for recursion
/// </summary>
private static string GetJsonPath_internal(Expression expression)
{
if (expression is null)
{
throw new ArgumentNullException(nameof(expression));
}

var path = new List<string>();
Expression? current = expression;
while (current is not null)
{
switch (current)
{
case MemberExpression memberExpression:
path.Add(GetJsonPropertyName(memberExpression.Member));
current = memberExpression.Expression;
break;
case UnaryExpression unaryExpression:
current = unaryExpression.Operand;
break;
case LambdaExpression lambdaExpression:
current = lambdaExpression.Body;
break;
case ParameterExpression typedParameterExpression:
current = null;
break;

// This is a special case for accessing a list item by index
case MethodCallExpression { Method.Name: "get_Item", Arguments: [ ConstantExpression { Value: Int32 value } ], Object: MemberExpression memberExpression } methodCallExpression:
// path[0] = ($"{path[0]}[{value}]");
path.Add($"{GetJsonPropertyName(memberExpression.Member)}[{value}]");
current = memberExpression.Expression;
break;
// This is a special case for selecting all childern of a list using Select
case MethodCallExpression { Method.Name: "Select" } methodCallExpression:
path.Add(GetJsonPath_internal(methodCallExpression.Arguments[1]));
current = methodCallExpression.Arguments[0];
break;
default:
throw new ArgumentException($"Invalid expression {expression}. Failed reading {current}");
}
}

path.Reverse();
return string.Join(".", path);
}

private static string GetJsonPropertyName(MemberInfo memberExpressionMember)
{
var jsonPropertyAttribute = memberExpressionMember.GetCustomAttribute<JsonPropertyNameAttribute>();
if (jsonPropertyAttribute is not null)
{
return jsonPropertyAttribute.Name;
}

return memberExpressionMember.Name;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.Json.Serialization;
using Altinn.App.Core.Features.Validation;
using Altinn.Platform.Storage.Interface.Models;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace Altinn.App.Core.Tests.Features.Validators;

public class GenericValidatorTests
{
private class MyModel
{
[JsonPropertyName("name")]
public string? Name { get; set; }

[JsonPropertyName("age")]
public int? Age { get; set; }

[JsonPropertyName("children")]
public List<MyModel>? Children { get; set; }
}

private class TestValidator : GenericFormDataValidator<MyModel>
{
public TestValidator() : base("MyType")
{
}

// Custom method to make the protected RunFor possible to call from the test
public void RunForExternal(Expression<Func<MyModel, object?>> selector)
{
RunFor(selector);
}

protected override async Task ValidateFormData(Instance instance, DataElement dataElement, MyModel data, List<string>? changedFields = null)
{
throw new NotImplementedException();
}
}

[Fact]
public void TestShouldRun()
{
var testValidator = new TestValidator();
testValidator.RunForExternal(m => m.Name);
testValidator.ShouldRun().Should().BeTrue();
testValidator.ShouldRun(new List<string>() { "name" }).Should().BeTrue();
testValidator.ShouldRun(new List<string>() { "age" }).Should().BeFalse();
}

[Theory]
[InlineData("name", false)]
[InlineData("age", false)]
[InlineData("children", true)]
[InlineData("children[0]", true)]
[InlineData("children[0].age", false)]
[InlineData("children[2]", false)]
public void TestShouldRunWithIndexedRow(string changedField, bool shouldBe)
{
var testValidator = new TestValidator();
testValidator.RunForExternal(m => m.Children![0].Name);
testValidator.ShouldRun(new List<string>() { changedField }).Should().Be(shouldBe);
}
}
Loading

0 comments on commit 07726d6

Please sign in to comment.