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 c2995db
Show file tree
Hide file tree
Showing 9 changed files with 636 additions and 0 deletions.
38 changes: 38 additions & 0 deletions src/Altinn.App.Core/Features/IFormDataValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.Extensions.DependencyInjection;

namespace Altinn.App.Core.Features;

public interface IFormDataValidator
{
/// <summary>
/// The data type this validator is for. Typically either hard coded by implementation or
/// or set by constructor using a <see cref="ServiceKeyAttribute" /> and a keyed service.
/// </summary>
string DataType { get; }

/// <summary>
/// Used for partial validation to ensure that the validator only runs
/// </summary>
bool ShouldRun(List<string>? changedFields = null);

/// <summary>
/// Returns the group id of the validator. This is used to run partial validations on the backend.
/// </summary>
/// <remarks>
/// The default implementation only works if a validator class is registered once.
/// If your validator can be registered multiple times, you need to override this method.
/// </remarks>
public string Code => this.GetType().FullName ?? string.Empty;

/// <summary>
///
/// </summary>
/// <param name="instance"></param>
/// <param name="dataElement"></param>
/// <param name="data"></param>
/// <param name="changedFields"></param>
/// <returns>List of validation issues</returns>
Task<List<ValidationIssue>> ValidateFormData(Instance instance, DataElement dataElement, object data, List<string>? changedFields = null);
}
39 changes: 39 additions & 0 deletions src/Altinn.App.Core/Features/ITaskValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.Extensions.DependencyInjection;

namespace Altinn.App.Core.Features;

/// <summary>
/// Interface for handling validation of tasks.
/// </summary>
public interface ITaskValidator
{
/// <summary>
/// The task id this validator is for. Typically either hard coded by implementation or
/// or set by constructor using a <see cref="ServiceKeyAttribute" /> and a keyed service.
/// </summary>
/// <example>
/// <code>
/// string TaskId { get; init; }
/// // constructor
/// public MyTaskValidator([ServiceKey] string taskId)
/// {
/// TaskId = taskId;
/// }
/// </code>
/// </example>
string TaskId { get; }

/// <summary>
/// Unique code for the validator. Used to run partial validations on the backend.
/// </summary>
public string Code => this.GetType().FullName ?? string.Empty;

/// <summary>
/// Actual validation logic for the task
/// </summary>
/// <param name="instance">The instance to validate</param>
/// <returns>List of validation issues to add to this task validation</returns>
Task<List<ValidationIssue>> ValidateTask(Instance instance);
}
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 and ensures that the instance is read for process next.
/// </summary>
Task<List<ValidationIssue>> ValidateTask(Instance instance);

/// <summary>
/// Validates a single data element. Used by frontend to continuously validate form data.
/// </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
{
/// <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;
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
}

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);
}
119 changes: 119 additions & 0 deletions src/Altinn.App.Core/Features/Validation/ValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Altinn.App.Core.Internal.App;
using Altinn.App.Core.Internal.AppModel;
using Altinn.App.Core.Internal.Data;
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.Extensions.DependencyInjection;

namespace Altinn.App.Core.Features.Validation;

public class ValidationService : IValidationService
{
private readonly IServiceProvider _serviceProvider;
private readonly IDataClient _dataClient;
private readonly IAppModel _appModel;
private readonly IAppMetadata _appMetadata;

public ValidationService(IServiceProvider serviceProvider, IDataClient dataClient, IAppModel appModel, IAppMetadata appMetadata)
{
_serviceProvider = serviceProvider;
_dataClient = dataClient;
_appModel = appModel;
_appMetadata = appMetadata;
}

/// <summary>
/// Run all validations for a task, and return a list of issues.
/// </summary>
/// <param name="instance">The instance to validate</param>
/// <returns>List of validation issues</returns>
public async Task<List<ValidationIssue>> ValidateTask(Instance instance)
{
var issues = new List<ValidationIssue>();
var taskId = instance.Process.CurrentTask.Name;

// Run task validations
var taskValidators = _serviceProvider.GetServices<ITaskValidator>();
taskValidators = taskValidators.Concat(_serviceProvider.GetKeyedServices<ITaskValidator>(taskId));
foreach (var taskValidator in taskValidators)
{
var code = taskValidator.Code;
if (taskValidator.TaskId == taskId)
{
var taskIssues = await taskValidator.ValidateTask(instance);
taskIssues.ForEach(i=>i.Code = code);
issues.AddRange(taskIssues);
}
}

// Run data validations
var appMetadata = await _appMetadata.GetApplicationMetadata();
var dataTypes = appMetadata.DataTypes.Where(dt => dt.TaskId == taskId);
foreach (var dataElement in instance.Data)
{
// ReSharper disable once PossibleMultipleEnumeration
var dataType = dataTypes.FirstOrDefault(dt => dt.Id == dataElement.DataType);
if(dataType is null) continue; // Ignore data elements that are not part of the current task
var dataValidators = _serviceProvider.GetServices<IFormDataValidator>();
dataValidators = dataValidators.Concat(_serviceProvider.GetKeyedServices<IFormDataValidator>(dataElement.DataType));
Type modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef);
Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]);
string app = instance.AppId.Split("/")[1];
int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId);
var data = await _dataClient.GetFormData(instanceGuid, modelType, instance.Org, app, instanceOwnerPartyId, Guid.Parse(dataElement.Id)); // TODO: Add method that accepts instance and dataElement
foreach (var dataValidator in dataValidators)
{
if (dataValidator.DataType == dataElement.DataType)
{
// TODO: ensure validators run in parallel
var dataIssues = await dataValidator.ValidateFormData(instance, dataElement, data);
dataIssues.ForEach(i=>i.Code = dataValidator.Code);
issues.AddRange(dataIssues);
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.
}

return issues;
}

/// <inheritdoc/>
public async Task<Dictionary<string,List<ValidationIssue>>> ValidateDataElement(Instance instance, DataElement dataElement, object data, List<string>? changedFields = null)
{
var issues = new Dictionary<string,List<ValidationIssue>>();

// Locate the relevant data validator services from normal and keyed services
var dataValidators = _serviceProvider.GetServices<IFormDataValidator>();
if (dataElement.DataType is not null)
{
dataValidators =
dataValidators.Concat(_serviceProvider.GetKeyedServices<IFormDataValidator>(dataElement.DataType));
}

// Start the validators for this data type if they should run given the changed fields
List<Task<List<ValidationIssue>>> validationTasks = new ();
List<string> validationCodes = new ();
foreach (var dataValidator in dataValidators)
{
if (dataValidator.DataType == dataElement.DataType)
{
if (dataValidator.ShouldRun(changedFields))
{
validationTasks.Add(dataValidator.ValidateFormData(instance, dataElement, data));
validationCodes.Add(dataValidator.Code);
}
}

Check notice

Code scanning / CodeQL

Nested 'if' statements can be combined Note

These 'if' statements can be combined.
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

// Ensure that all validators are run in parallel
List<ValidationIssue>[] validationIssues = await Task.WhenAll(validationTasks);

// Add the validation issues to the dictionary with the correct code
for (var i = 0; i < validationIssues.Length; i++)
{
validationIssues[i].ForEach(vi => vi.Code = validationCodes[i]);
issues.Add(validationCodes[i], validationIssues[i]);
}

return issues;
}
}
Loading

0 comments on commit c2995db

Please sign in to comment.