-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
- Loading branch information
There are no files selected for viewing
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); | ||
} |
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 GitHub Actions / Analyze (csharp)
Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs GitHub Actions / Analyze (csharp)
Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs GitHub Actions / Static code analysis
Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs GitHub Actions / Run dotnet build and test (ubuntu-latest)
Check failure on line 13 in src/Altinn.App.Core/Features/Validation/GenericFormDataValidator.cs GitHub Actions / Run dotnet build and test (ubuntu-latest)
|
||
{ | ||
/// <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); | ||
} |
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); | ||
} | ||
} |