From cf1bbf50ead9193eb3129736cfc5fdfe12fef928 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Tue, 9 Jul 2024 15:35:03 +0200 Subject: [PATCH 01/63] Removes form-data delete check --- src/Altinn.App.Api/Controllers/DataController.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index fe709a6e9..bbe37ca1b 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -570,11 +570,13 @@ [FromRoute] Guid dataGuid _logger.LogError(errorMsg); return BadRequest(errorMsg); } - else if (dataType.AppLogic?.ClassRef is not null) - { - // trying deleting a form element - return BadRequest("Deleting form data is not possible at this moment."); - } + + // TODO: Fix this, specify allowable for subform data, while leaving legacy functionality? + // else if (dataType.AppLogic?.ClassRef is not null) + // { + // // trying deleting a form element + // return BadRequest("Deleting form data is not possible at this moment."); + // } return await DeleteBinaryData(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); } From 779880318967ce4385d00f73499ed1e4de9348db Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Tue, 16 Jul 2024 14:43:54 +0200 Subject: [PATCH 02/63] Implements `AllowUserCreate` and `AllowUserDelete` methodology. Fixes various tests and minor issues --- .../Controllers/DataController.cs | 248 +++++++++--------- .../Controllers/DataController_PutTests.cs | 4 +- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 30 +++ .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 21 ++ .../Utils/PrincipalUtil.cs | 16 +- .../Internal/App/AppMedataTest.cs | 5 +- ...operties.applicationmetadata.expected.json | 9 +- 7 files changed, 197 insertions(+), 136 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index bbe37ca1b..5f39f44b7 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -137,7 +137,7 @@ [FromQuery] string dataType e.Id.Equals(dataType, StringComparison.OrdinalIgnoreCase) ); - if (dataTypeFromMetadata == null) + if (dataTypeFromMetadata is null) { return BadRequest( $"Element type {dataType} not allowed for instance {instanceOwnerPartyId}/{instanceGuid}." @@ -149,10 +149,8 @@ [FromQuery] string dataType return Forbid(); } - bool appLogic = dataTypeFromMetadata.AppLogic?.ClassRef != null; - Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance == null) + if (instance is null) { return NotFound($"Did not find instance {instance}"); } @@ -164,78 +162,80 @@ [FromQuery] string dataType ); } - if (appLogic) + if (dataTypeFromMetadata.AppLogic is not null) { - return await CreateAppModelData(org, app, instance, dataType); - } - else - { - (bool validationRestrictionSuccess, List errors) = - DataRestrictionValidation.CompliesWithDataRestrictions(Request, dataTypeFromMetadata); - if (!validationRestrictionSuccess) + // TODO: How does this affect other implementations?? + // TODO: Is the `urn:altinn:org` claim how we identify instance creation by service owners/automation, as opposed to users? + if (!dataTypeFromMetadata.AppLogic.AllowUserCreate && !UserHasValidOrgClaim()) { - return BadRequest(await GetErrorDetails(errors)); + return BadRequest($"Element type `{dataType}` cannot be manually created."); } - StreamContent streamContent = Request.CreateContentStream(); + return await CreateAppModelData(org, app, instance, dataType); + } - using Stream fileStream = new MemoryStream(); - await streamContent.CopyToAsync(fileStream); - if (fileStream.Length == 0) - { - const string errorMessage = "Invalid data provided. Error: The file is zero bytes."; - var error = new ValidationIssue - { - Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, - Severity = ValidationIssueSeverity.Error, - Description = errorMessage - }; - _logger.LogError(errorMessage); - return BadRequest(await GetErrorDetails(new List { error })); - } + (bool validationRestrictionSuccess, List errors) = + DataRestrictionValidation.CompliesWithDataRestrictions(Request, dataTypeFromMetadata); - bool parseSuccess = Request.Headers.TryGetValue("Content-Disposition", out StringValues headerValues); - string? filename = parseSuccess ? DataRestrictionValidation.GetFileNameFromHeader(headerValues) : null; + if (!validationRestrictionSuccess) + { + return BadRequest(await GetErrorDetails(errors)); + } - IEnumerable fileAnalysisResults = new List(); - if (FileAnalysisEnabledForDataType(dataTypeFromMetadata)) - { - fileAnalysisResults = await _fileAnalyserService.Analyse( - dataTypeFromMetadata, - fileStream, - filename - ); - } + StreamContent streamContent = Request.CreateContentStream(); - bool fileValidationSuccess = true; - List validationIssues = new(); - if (FileValidationEnabledForDataType(dataTypeFromMetadata)) + using Stream fileStream = new MemoryStream(); + await streamContent.CopyToAsync(fileStream); + if (fileStream.Length is 0) + { + const string errorMessage = "Invalid data provided. Error: The file is zero bytes."; + var error = new ValidationIssue { - (fileValidationSuccess, validationIssues) = await _fileValidationService.Validate( - dataTypeFromMetadata, - fileAnalysisResults - ); - } + Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, + Severity = ValidationIssueSeverity.Error, + Description = errorMessage + }; + _logger.LogError(errorMessage); + return BadRequest(await GetErrorDetails([error])); + } - if (!fileValidationSuccess) - { - return BadRequest(await GetErrorDetails(validationIssues)); - } + bool parseSuccess = Request.Headers.TryGetValue("Content-Disposition", out StringValues headerValues); + string? filename = parseSuccess ? DataRestrictionValidation.GetFileNameFromHeader(headerValues) : null; - if (streamContent.Headers.ContentType is null) - { - return StatusCode(500, "Content-Type not defined"); - } + IEnumerable fileAnalysisResults = new List(); + if (FileAnalysisEnabledForDataType(dataTypeFromMetadata)) + { + fileAnalysisResults = await _fileAnalyserService.Analyse(dataTypeFromMetadata, fileStream, filename); + } - fileStream.Seek(0, SeekOrigin.Begin); - return await CreateBinaryData( - instance, - dataType, - streamContent.Headers.ContentType.ToString(), - filename, - fileStream + var fileValidationSuccess = true; + List validationIssues = []; + if (FileValidationEnabledForDataType(dataTypeFromMetadata)) + { + (fileValidationSuccess, validationIssues) = await _fileValidationService.Validate( + dataTypeFromMetadata, + fileAnalysisResults ); } + + if (!fileValidationSuccess) + { + return BadRequest(await GetErrorDetails(validationIssues)); + } + + if (streamContent.Headers.ContentType is null) + { + return StatusCode((int)HttpStatusCode.InternalServerError, "Content-Type not defined"); + } + + fileStream.Seek(0, SeekOrigin.Begin); + return await CreateBinaryData( + instance, + dataType, + streamContent.Headers.ContentType.ToString(), + filename, + fileStream + ); } catch (PlatformHttpException e) { @@ -263,13 +263,12 @@ private async Task GetErrorDetails(List errors) private static bool FileAnalysisEnabledForDataType(DataType dataTypeFromMetadata) { - return dataTypeFromMetadata.EnabledFileAnalysers != null && dataTypeFromMetadata.EnabledFileAnalysers.Count > 0; + return dataTypeFromMetadata.EnabledFileAnalysers is { Count: > 0 }; } private static bool FileValidationEnabledForDataType(DataType dataTypeFromMetadata) { - return dataTypeFromMetadata.EnabledFileValidators != null - && dataTypeFromMetadata.EnabledFileValidators.Count > 0; + return dataTypeFromMetadata.EnabledFileValidators is { Count: > 0 }; } /// @@ -298,7 +297,7 @@ public async Task Get( try { Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance == null) + if (instance is null) { return NotFound($"Did not find instance {instance}"); } @@ -307,7 +306,7 @@ public async Task Get( m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) ); - if (dataElement == null) + if (dataElement is null) { return NotFound("Did not find data element"); } @@ -316,11 +315,12 @@ public async Task Get( if (dataType is null) { - string error = $"Could not determine if {dataType} requires app logic for application {org}/{app}"; + var error = $"Could not determine if {dataType} requires app logic for application {org}/{app}"; _logger.LogError(error); return BadRequest(error); } - else if (dataType.AppLogic?.ClassRef is not null) + + if (dataType.AppLogic?.ClassRef is not null) { return await GetFormData( org, @@ -387,7 +387,7 @@ public async Task Put( m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) ); - if (dataElement == null) + if (dataElement is null) { return NotFound("Did not find data element"); } @@ -404,13 +404,15 @@ public async Task Put( ); return BadRequest($"Could not determine if data type {dataType} requires application logic."); } - else if (dataType.AppLogic?.ClassRef is not null) + + if (dataType.AppLogic?.ClassRef is not null) { return await PutFormData(org, app, instance, dataGuid, dataType, language); } (bool validationRestrictionSuccess, List errors) = DataRestrictionValidation.CompliesWithDataRestrictions(Request, dataType); + if (!validationRestrictionSuccess) { return BadRequest(await GetErrorDetails(errors)); @@ -466,7 +468,7 @@ public async Task> PatchFormData( var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); - if (dataElement == null) + if (dataElement is null) { return NotFound("Did not find data element"); } @@ -540,7 +542,7 @@ [FromRoute] Guid dataGuid try { Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance == null) + if (instance is null) { return NotFound("Did not find instance"); } @@ -556,14 +558,14 @@ [FromRoute] Guid dataGuid m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) ); - if (dataElement == null) + if (dataElement is null) { return NotFound("Did not find data element"); } DataType? dataType = await GetDataType(dataElement); - if (dataType == null) + if (dataType is null) { string errorMsg = $"Could not determine if {dataElement.DataType} requires app logic for application {org}/{app}"; @@ -571,12 +573,14 @@ [FromRoute] Guid dataGuid return BadRequest(errorMsg); } - // TODO: Fix this, specify allowable for subform data, while leaving legacy functionality? - // else if (dataType.AppLogic?.ClassRef is not null) - // { - // // trying deleting a form element - // return BadRequest("Deleting form data is not possible at this moment."); - // } + if ( + dataType.AppLogic?.ClassRef is not null + && !dataType.AppLogic.AllowUserDelete + && !UserHasValidOrgClaim() + ) + { + return BadRequest("Deleting form data is not possible at this moment."); + } return await DeleteBinaryData(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); } @@ -597,12 +601,13 @@ private ObjectResult ExceptionResponse(Exception exception, string message) { return StatusCode((int)phe.Response.StatusCode, phe.Message); } - else if (exception is ServiceException se) + + if (exception is ServiceException se) { return StatusCode((int)se.StatusCode, se.Message); } - return StatusCode(500, $"{message}"); + return StatusCode((int)HttpStatusCode.InternalServerError, $"{message}"); } private async Task CreateBinaryData( @@ -626,7 +631,10 @@ Stream fileStream if (Guid.Parse(dataElement.Id) == Guid.Empty) { - return StatusCode(500, $"Cannot store form attachment on instance {instanceOwnerPartyId}/{instanceGuid}"); + return StatusCode( + (int)HttpStatusCode.InternalServerError, + $"Cannot store form attachment on instance {instanceOwnerPartyId}/{instanceGuid}" + ); } SelfLinkHelper.SetDataAppSelfLinks(instanceOwnerPartyId, instanceGuid, dataElement, Request); @@ -641,7 +649,7 @@ private async Task CreateAppModelData(string org, string app, Inst string classRef = _appResourcesService.GetClassRefForLogicDataType(dataType); - if (Request.ContentType == null) + if (Request.ContentType is null) { appModel = _appModel.Create(classRef); } @@ -664,7 +672,7 @@ private async Task CreateAppModelData(string org, string app, Inst await UpdatePresentationTextsOnInstance(instance, dataType, appModel); await UpdateDataValuesOnInstance(instance, dataType, appModel); - int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + var instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); ObjectUtils.InitializeAltinnRowId(appModel); ObjectUtils.PrepareModelForXmlStorage(appModel); @@ -698,20 +706,18 @@ DataElement dataElement { Stream dataStream = await _dataClient.GetBinaryData(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); - if (dataStream != null) + if (dataStream is not null) { string? userOrgClaim = User.GetOrg(); - if (userOrgClaim == null || !org.Equals(userOrgClaim, StringComparison.OrdinalIgnoreCase)) + if (userOrgClaim is null || !org.Equals(userOrgClaim, StringComparison.OrdinalIgnoreCase)) { await _instanceClient.UpdateReadStatus(instanceOwnerPartyId, instanceGuid, "read"); } return File(dataStream, dataElement.ContentType, dataElement.Filename); } - else - { - return NotFound(); - } + + return NotFound(); } private async Task DeleteBinaryData( @@ -735,13 +741,11 @@ Guid dataGuid { return Ok(); } - else - { - return StatusCode( - 500, - $"Something went wrong when deleting data element {dataGuid} for instance {instanceGuid}" - ); - } + + return StatusCode( + (int)HttpStatusCode.InternalServerError, + $"Something went wrong when deleting data element {dataGuid} for instance {instanceGuid}" + ); } private async Task GetDataType(DataElement element) @@ -779,7 +783,7 @@ private async Task GetFormData( dataGuid ); - if (appModel == null) + if (appModel is null) { return BadRequest($"Did not find form data for data element {dataGuid}"); } @@ -790,7 +794,7 @@ private async Task GetFormData( foreach (var dataProcessor in _dataProcessors) { _logger.LogInformation( - "ProcessDataRead for {modelType} using {dataProcesor}", + "ProcessDataRead for {ModelType} using {DataProcessor}", appModel.GetType().Name, dataProcessor.GetType().Name ); @@ -817,7 +821,7 @@ await _dataClient.UpdateData( dataGuid ); } - catch (PlatformHttpException e) when (e.Response.StatusCode == HttpStatusCode.Forbidden) + catch (PlatformHttpException e) when (e.Response.StatusCode is HttpStatusCode.Forbidden) { _logger.LogInformation("User does not have write access to the data element. Skipping update."); } @@ -831,7 +835,7 @@ await _dataClient.UpdateData( // This is likely not required as the instance is already read string? userOrgClaim = User.GetOrg(); - if (userOrgClaim == null || !org.Equals(userOrgClaim, StringComparison.OrdinalIgnoreCase)) + if (userOrgClaim is null || !org.Equals(userOrgClaim, StringComparison.OrdinalIgnoreCase)) { await _instanceClient.UpdateReadStatus(instanceOwnerId, instanceGuid, "read"); } @@ -882,7 +886,7 @@ private async Task PutFormData( return BadRequest(deserializer.Error); } - if (serviceModel == null) + if (serviceModel is null) { return BadRequest("No data found in content"); } @@ -965,37 +969,23 @@ await _instanceClient.UpdateDataValues( private ActionResult HandlePlatformHttpException(PlatformHttpException e, string defaultMessage) { - if (e.Response.StatusCode == HttpStatusCode.Forbidden) - { - return Forbid(); - } - else if (e.Response.StatusCode == HttpStatusCode.NotFound) - { - return NotFound(); - } - else if (e.Response.StatusCode == HttpStatusCode.Conflict) - { - return Conflict(); - } - else + return e.Response.StatusCode switch { - return ExceptionResponse(e, defaultMessage); - } + HttpStatusCode.Forbidden => Forbid(), + HttpStatusCode.NotFound => NotFound(), + HttpStatusCode.Conflict => Conflict(), + _ => ExceptionResponse(e, defaultMessage) + }; } private static bool InstanceIsActive(Instance i) { - if (i?.Status?.Archived != null || i?.Status?.SoftDeleted != null || i?.Status?.HardDeleted != null) - { - return false; - } - - return true; + return i?.Status?.Archived is null && i?.Status?.SoftDeleted is null && i?.Status?.HardDeleted is null; } private static bool IsValidContributer(DataType dataType, ClaimsPrincipal user) { - if (dataType.AllowedContributers == null || dataType.AllowedContributers.Count == 0) + if (dataType.AllowedContributers is null || dataType.AllowedContributers.Count is 0) { return true; } @@ -1042,6 +1032,7 @@ private ObjectResult Problem(DataPatchError error) DataPatchErrorType.DeserializationFailed => (int)HttpStatusCode.UnprocessableContent, _ => (int)HttpStatusCode.InternalServerError }; + return StatusCode( code, new ProblemDetails() @@ -1054,4 +1045,9 @@ private ObjectResult Problem(DataPatchError error) } ); } + + /// + /// Checks if the current claims principal has a valid `urn:altinn:org` claim + /// + private bool UserHasValidOrgClaim() => !string.IsNullOrWhiteSpace(User.GetOrg()); } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs index b4a8b6af1..14ec564f7 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs @@ -33,7 +33,7 @@ public async Task PutDataElement_TestSinglePartUpdate_ReturnsOk() string app = "contributer-restriction"; int instanceOwnerPartyId = 501337; HttpClient client = GetRootedClient(org, app); - string token = PrincipalUtil.GetToken(1337, null); + string token = PrincipalUtil.GetToken(1337, null, org: "abc"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Create instance @@ -137,7 +137,7 @@ public async Task PutDataElement_TestMultiPartUpdateWithCustomDataProcessor_Retu string app = "contributer-restriction"; int instanceOwnerPartyId = 501337; HttpClient client = GetRootedClient(org, app); - string token = PrincipalUtil.GetToken(1337, null); + string token = PrincipalUtil.GetToken(1337, null, org: "abc"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Create instance diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 4779a497a..aa2623d44 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -4643,6 +4643,12 @@ "autoDeleteOnProcessEnd": { "type": "boolean" }, + "allowUserCreate": { + "type": "boolean" + }, + "allowUserDelete": { + "type": "boolean" + }, "shadowFields": { "$ref": "#/components/schemas/ShadowFields" } @@ -4739,6 +4745,9 @@ "copyInstanceSettings": { "$ref": "#/components/schemas/CopyInstanceSettings" }, + "disallowUserInstantiation": { + "type": "boolean" + }, "id": { "type": "string", "nullable": true @@ -4838,6 +4847,13 @@ }, "nullable": true }, + "userDefinedMetadata": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValueEntry" + }, + "nullable": true + }, "metadata": { "type": "array", "items": { @@ -5085,6 +5101,13 @@ }, "nullable": true }, + "userDefinedMetadata": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyValueEntry" + }, + "nullable": true + }, "metadata": { "type": "array", "items": { @@ -5258,6 +5281,13 @@ "type": "string" }, "nullable": true + }, + "allowedKeysForUserDefinedMetadata": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true } }, "additionalProperties": false diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index a7e893eca..851b9f675 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -2850,6 +2850,10 @@ components: type: boolean autoDeleteOnProcessEnd: type: boolean + allowUserCreate: + type: boolean + allowUserDelete: + type: boolean shadowFields: $ref: '#/components/schemas/ShadowFields' additionalProperties: false @@ -2920,6 +2924,8 @@ components: $ref: '#/components/schemas/MessageBoxConfig' copyInstanceSettings: $ref: '#/components/schemas/CopyInstanceSettings' + disallowUserInstantiation: + type: boolean id: type: string nullable: true @@ -2993,6 +2999,11 @@ components: items: type: string nullable: true + userDefinedMetadata: + type: array + items: + $ref: '#/components/schemas/KeyValueEntry' + nullable: true metadata: type: array items: @@ -3173,6 +3184,11 @@ components: items: type: string nullable: true + userDefinedMetadata: + type: array + items: + $ref: '#/components/schemas/KeyValueEntry' + nullable: true metadata: type: array items: @@ -3297,6 +3313,11 @@ components: items: type: string nullable: true + allowedKeysForUserDefinedMetadata: + type: array + items: + type: string + nullable: true additionalProperties: false DeleteStatus: type: object diff --git a/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs b/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs index cf3277a96..7ead14239 100644 --- a/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs +++ b/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs @@ -6,14 +6,19 @@ namespace Altinn.App.Api.Tests.Utils; public static class PrincipalUtil { - public static string GetToken(int? userId, int? partyId, int authenticationLevel = 2) + public static string GetToken(int? userId, int? partyId, int authenticationLevel = 2, string? org = null) { - ClaimsPrincipal principal = GetUserPrincipal(userId, partyId, authenticationLevel); + ClaimsPrincipal principal = GetUserPrincipal(userId, partyId, authenticationLevel, org); string token = JwtTokenMock.GenerateToken(principal, new TimeSpan(1, 1, 1)); return token; } - public static ClaimsPrincipal GetUserPrincipal(int? userId, int? partyId, int authenticationLevel = 2) + public static ClaimsPrincipal GetUserPrincipal( + int? userId, + int? partyId, + int authenticationLevel = 2, + string? org = null + ) { List claims = new List(); string issuer = "www.altinn.no"; @@ -31,6 +36,11 @@ public static ClaimsPrincipal GetUserPrincipal(int? userId, int? partyId, int au ); } + if (org is not null) + { + claims.Add(new Claim(AltinnCoreClaimTypes.Org, org, ClaimValueTypes.String, issuer)); + } + claims.Add(new Claim(AltinnCoreClaimTypes.UserName, $"User{userId}", ClaimValueTypes.String, issuer)); claims.Add(new Claim(AltinnCoreClaimTypes.AuthenticateMethod, "Mock", ClaimValueTypes.String, issuer)); claims.Add( diff --git a/test/Altinn.App.Core.Tests/Internal/App/AppMedataTest.cs b/test/Altinn.App.Core.Tests/Internal/App/AppMedataTest.cs index 74a0af033..8c314c751 100644 --- a/test/Altinn.App.Core.Tests/Internal/App/AppMedataTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/App/AppMedataTest.cs @@ -454,8 +454,9 @@ public async Task GetApplicationMetadata_deserialize_serialize_unmapped_properti var appMetadataObj = await appMetadata.GetApplicationMetadata(); string serialized = JsonSerializer.Serialize(appMetadataObj, _jsonSerializerOptions); string expected = File.ReadAllText( - Path.Join(appBasePath, "AppMetadata", "unmapped-properties.applicationmetadata.expected.json") - ); + Path.Join(appBasePath, "AppMetadata", "unmapped-properties.applicationmetadata.expected.json") + ) + .TrimEnd(); expected = expected.Replace( "--AltinnNugetVersion--", typeof(ApplicationMetadata).Assembly!.GetName().Version!.ToString() diff --git a/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json b/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json index 88b4be5b3..acd005aca 100644 --- a/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json +++ b/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json @@ -39,7 +39,8 @@ "EnableFileScan": false, "ValidationErrorOnPendingFileScan": false, "EnabledFileAnalysers": [], - "EnabledFileValidators": [] + "EnabledFileValidators": [], + "AllowedKeysForUserDefinedMetadata": null }, { "Id": "ref-data-as-pdf", @@ -58,7 +59,8 @@ "EnableFileScan": false, "ValidationErrorOnPendingFileScan": false, "EnabledFileAnalysers": [], - "EnabledFileValidators": [] + "EnabledFileValidators": [], + "AllowedKeysForUserDefinedMetadata": null } ], "PartyTypesAllowed": { @@ -73,6 +75,7 @@ "EFormidling": null, "MessageBoxConfig": null, "CopyInstanceSettings": null, + "DisallowUserInstantiation": false, "Created": "2019-09-16T22:22:22", "CreatedBy": "username", "LastChanged": null, @@ -80,4 +83,4 @@ "foo": { "bar": "baz" } -} \ No newline at end of file +} From ad56781b060608a61ffd7b70e806da626b22a129 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Tue, 16 Jul 2024 14:47:38 +0200 Subject: [PATCH 03/63] Temp project ref to Storage.Interface --- src/Altinn.App.Api/Altinn.App.Api.csproj | 7 ++++++- src/Altinn.App.Core/Altinn.App.Core.csproj | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Api/Altinn.App.Api.csproj b/src/Altinn.App.Api/Altinn.App.Api.csproj index 34ddb661f..654d8d836 100644 --- a/src/Altinn.App.Api/Altinn.App.Api.csproj +++ b/src/Altinn.App.Api/Altinn.App.Api.csproj @@ -16,7 +16,12 @@ - + + + + + + diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index ba6b35513..3a58621b4 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -15,7 +15,12 @@ - + + + + + + @@ -31,4 +36,4 @@ - \ No newline at end of file + From 54848541f9fc7d06b8dee7601dfa8def773c6012 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Wed, 17 Jul 2024 14:21:57 +0200 Subject: [PATCH 04/63] Access testing for DataController -> `Create` and `Delete` --- .../DataController_UserAccessTests.cs | 128 ++++++++++++++++++ .../config/applicationmetadata.json | 124 +++++++++++------ 2 files changed, 208 insertions(+), 44 deletions(-) create mode 100644 test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs new file mode 100644 index 000000000..3327b3648 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Headers; +using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; +using Altinn.App.Api.Tests.Utils; +using Altinn.App.Core.Features; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Controllers; + +public class DataController_UserAccessTests : ApiTestBase, IClassFixture> +{ + private readonly Mock _dataProcessor = new(); + const string OrgId = "tdd"; + const string AppId = "contributer-restriction"; + + public DataController_UserAccessTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) + : base(factory, outputHelper) + { + OverrideServicesForAllTests = (services) => + { + services.AddSingleton(_dataProcessor.Object); + }; + } + + [Theory] + [InlineData("default", null, HttpStatusCode.BadRequest)] + [InlineData("default", OrgId, HttpStatusCode.Created)] + [InlineData("userCreateEnabled", null, HttpStatusCode.Created)] + [InlineData("userCreateEnabled", OrgId, HttpStatusCode.Created)] + public async Task CreateDataElement_ImplementsAndValidates_AllowUserCreateProperty( + string dataModelId, + string? tokenOrgClaim, + HttpStatusCode expectedStatusCode + ) + { + // Arrange + var instance = await CreateAppInstance(tokenOrgClaim); + + // Act + var response = await instance.AuthenticatedClient.PostAsync( + $"/{instance.Org}/{instance.App}/instances/{instance.Id}/data?dataType={dataModelId}", + null + ); + + // Assert + response.Should().HaveStatusCode(expectedStatusCode); + } + + [Theory] + [InlineData("default", null, HttpStatusCode.BadRequest)] + [InlineData("default", OrgId, HttpStatusCode.OK)] + [InlineData("userDeleteEnabled", null, HttpStatusCode.OK)] + [InlineData("userDeleteEnabled", OrgId, HttpStatusCode.OK)] + public async Task DeleteDataElement_ImplementsAndValidates_AllowUserDeleteProperty( + string dataModelId, + string? tokenOrgClaim, + HttpStatusCode expectedStatusCode + ) + { + // Arrange + var instance = await CreateAppInstance(tokenOrgClaim); + + /* Create a datamodel so we have something to delete */ + var systemClient = CreateAuthenticatedHttpClient( + rootOrg: instance.Org, + rootApp: instance.App, + tokenOrgClaim: OrgId + ); + var createResponse = await systemClient.PostAsync( + $"/{instance.Org}/{instance.App}/instances/{instance.Id}/data?dataType={dataModelId}", + null + ); + var createResponseParsed = await VerifyStatusAndDeserialize( + createResponse, + HttpStatusCode.Created + ); + + // Act + var response = await instance.AuthenticatedClient.DeleteAsync( + $"/{instance.Org}/{instance.App}/instances/{instance.Id}/data/{createResponseParsed.Id}" + ); + + // Assert + response.Should().HaveStatusCode(expectedStatusCode); + } + + private async Task CreateAppInstance(string? tokenOrgClaim) + { + var instanceOwnerPartyId = 501337; + var userId = 1337; + HttpClient client = CreateAuthenticatedHttpClient( + rootOrg: OrgId, + rootApp: AppId, + tokenUserClaim: userId, + tokenOrgClaim: tokenOrgClaim + ); + + var response = await client.PostAsync( + $"{OrgId}/{AppId}/instances/?instanceOwnerPartyId={instanceOwnerPartyId}", + null + ); + var createResponseParsed = await VerifyStatusAndDeserialize(response, HttpStatusCode.Created); + + return new AppInstance(createResponseParsed.Id, OrgId, AppId, client); + } + + private HttpClient CreateAuthenticatedHttpClient( + string rootOrg, + string rootApp, + int? tokenUserClaim = default, + int? tokenPartyIdClaim = default, + string? tokenOrgClaim = default + ) + { + HttpClient client = GetRootedClient(rootOrg, rootApp); + string token = PrincipalUtil.GetToken(tokenUserClaim, tokenPartyIdClaim, org: tokenOrgClaim); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + return client; + } + + private record AppInstance(string Id, string Org, string App, HttpClient AuthenticatedClient); +} diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json index 57f86392d..526138d63 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json @@ -1,47 +1,83 @@ { - "id": "tdd/contributer-restriction", - "org": "tdd", - "created": "2019-09-24T10:02:41.0839253Z", - "createdBy": "Kritsi", - "lastChanged": "2019-09-24T10:02:41.0839254Z", - "lastChangedBy": "Kritsi", - "title": { - "nb": "Endring av navn (RF-1453)", - "nb-NO": "Endring av navn (RF-1453)" - }, - "dataTypes": [ - { - "id": "default", - "allowedContentTypes": [ "application/xml" ], - "maxCount": 1, - "appLogic": { - "autoCreate": true, - "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema" - }, - "taskId": "Task_1" + "id": "tdd/contributer-restriction", + "org": "tdd", + "created": "2019-09-24T10:02:41.0839253Z", + "createdBy": "Kritsi", + "lastChanged": "2019-09-24T10:02:41.0839254Z", + "lastChangedBy": "Kritsi", + "title": { + "nb": "Endring av navn (RF-1453)", + "nb-NO": "Endring av navn (RF-1453)" }, - { - "id": "customElement", - "maxCount": 1, - "allowedContributers": [ "org:tdd", "orgno:160694123", "invalidKey:value" ], - "taskId": "Task_1" - }, - { - "id": "9edd53de-f46f-40a1-bb4d-3efb93dc113d", - "taskId": "Task_1", - "maxSize": 1, - "maxCount": 1, - "minCount": 0 - }, - { - "id": "specificFileType", - "taskId": "Task_1", - "maxSize": 1, - "maxCount": 1, - "minCount": 0, - "allowedContentTypes": [ "application/pdf", "image/png", "application/json" ], - "enabledFileAnalysers": [ "mimeTypeAnalyser" ], - "enabledFileValidators": [ "mimeTypeValidator"] - } - ] + "dataTypes": [ + { + "id": "default", + "allowedContentTypes": [ + "application/xml" + ], + "maxCount": 1, + "appLogic": { + "autoCreate": true, + "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema" + }, + "taskId": "Task_1" + }, + { + "id": "customElement", + "maxCount": 1, + "allowedContributers": [ + "org:tdd", + "orgno:160694123", + "invalidKey:value" + ], + "taskId": "Task_1" + }, + { + "id": "9edd53de-f46f-40a1-bb4d-3efb93dc113d", + "taskId": "Task_1", + "maxSize": 1, + "maxCount": 1, + "minCount": 0 + }, + { + "id": "specificFileType", + "taskId": "Task_1", + "maxSize": 1, + "maxCount": 1, + "minCount": 0, + "allowedContentTypes": [ + "application/pdf", + "image/png", + "application/json" + ], + "enabledFileAnalysers": [ + "mimeTypeAnalyser" + ], + "enabledFileValidators": [ + "mimeTypeValidator" + ] + }, + { + "id": "userCreateEnabled", + "allowedContentTypes": [ + "application/xml" + ], + "appLogic": { + "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", + "allowUserCreate": true + }, + "taskId": "Task_1" + }, + { + "id": "userDeleteEnabled", + "allowedContentTypes": [ + "application/xml" + ], + "appLogic": { + "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", + "allowUserDelete": true + }, + "taskId": "Task_1" + } + ] } From 87cae853348883b13ade6ec74e19691a8c50edfe Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Wed, 24 Jul 2024 15:31:10 +0200 Subject: [PATCH 05/63] Verifies MaxCount before creating a new data element --- src/Altinn.App.Api/Controllers/DataController.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 5f39f44b7..1ceee6719 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -171,6 +171,14 @@ [FromQuery] string dataType return BadRequest($"Element type `{dataType}` cannot be manually created."); } + int existingElements = instance.Data.Count(d => d.DataType == dataTypeFromMetadata.Id); + if (dataTypeFromMetadata.MaxCount > 0 && existingElements >= dataTypeFromMetadata.MaxCount) + { + return Conflict( + $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})" + ); + } + return await CreateAppModelData(org, app, instance, dataType); } From 8d9d77db1e83c3df9d37f530ef71251db84d080e Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 15 Aug 2024 21:56:50 +0200 Subject: [PATCH 06/63] Implement support for multiple data models in expressions --- .../Controllers/ResourceController.cs | 9 +- .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Features/IFormDataValidator.cs | 4 +- .../Validation/Default/ExpressionValidator.cs | 109 +++--- .../Validation/Default/RequiredValidator.cs | 33 +- .../Helpers/DataModel/DataModel.cs | 274 +++++++++----- .../Helpers/DataModel/RowRemovalOption.cs | 22 ++ src/Altinn.App.Core/Helpers/IDataModel.cs | 69 ---- .../Implementation/AppResourcesSI.cs | 79 ++-- .../Internal/App/IAppResources.cs | 11 +- .../Internal/Data/CachedFormDataAccessor.cs | 149 ++++++++ .../Internal/Data/ICachedFormDataAccessor.cs | 21 ++ .../Expressions/ExpressionEvaluator.cs | 41 +- .../ILayoutEvaluatorStateInitializer.cs | 21 ++ .../Internal/Expressions/LayoutEvaluator.cs | 29 +- .../Expressions/LayoutEvaluatorState.cs | 75 ++-- .../LayoutEvaluatorStateInitializer.cs | 66 +++- .../Process/ExpressionsExclusiveGateway.cs | 128 +------ .../Common/ProcessTaskFinalizer.cs | 42 ++- .../Internal/Validation/ValidationService.cs | 105 +++--- .../Models/Expressions/ComponentContext.cs | 2 +- .../Models/Expressions/Expression.cs | 47 ++- .../Models/Expressions/ExpressionConverter.cs | 27 +- .../Expressions/ExpressionFunctionEnum.cs | 8 + .../Models/Layout/Components/BaseComponent.cs | 20 +- .../Models/Layout/Components/GridComponent.cs | 20 +- .../Layout/Components/GroupComponent.cs | 14 +- .../Layout/Components/OptionsComponent.cs | 12 +- .../Models/Layout/Components/PageComponent.cs | 8 +- .../Components/RepeatingGroupComponent.cs | 18 +- .../Layout/Components/SummaryComponent.cs | 8 +- .../Models/Layout/LayoutModel.cs | 66 +++- .../Models/Layout/ModelBinding.cs | 30 ++ .../Models/Layout/PageComponentConverter.cs | 111 ++++-- .../Controllers/DataController_PatchTests.cs | 29 +- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 4 +- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 4 +- .../Default/ExpressionValidatorTests.cs | 61 +-- .../Validators/ValidationServiceOldTests.cs | 7 + .../Validators/ValidationServiceTests.cs | 10 +- .../Helpers/JsonDataModel.cs | 349 ------------------ .../Internal/Patch/PatchServiceTests.cs | 25 +- .../ExpressionsExclusiveGatewayTests.cs | 95 +++-- .../Common/ProcessTaskFinalizerTests.cs | 66 ++-- .../CommonTests/.editorconfig | 4 + .../CommonTests/ContextListRoot.cs | 6 +- .../CommonTests/ExpressionTestCaseRoot.cs | 26 +- .../LayoutModelConverterFromObject.cs | 14 +- .../TestBackendExclusiveFunctions.cs | 9 +- .../CommonTests/TestContextList.cs | 15 +- .../CommonTests/TestFunctions.cs | 23 +- .../CommonTests/TestInvalid.cs | 9 +- .../component/hidden-in-group-other-row.json | 3 +- .../functions/component/hidden-in-group.json | 3 +- .../component/hide-group-component.json | 3 +- .../functions/dataModel/array-is-null.json | 25 +- .../dataModel/direct-reference-in-group.json | 28 +- .../direct-reference-in-nested-group.json | 54 +-- .../direct-reference-in-nested-group2.json | 54 +-- .../direct-reference-in-nested-group3.json | 54 +-- .../direct-reference-in-nested-group4.json | 54 +-- .../functions/dataModel/in-group.json | 28 +- .../functions/dataModel/in-nested-group.json | 54 +-- .../functions/dataModel/null-is-null.json | 14 +- .../functions/dataModel/null.json | 16 +- .../functions/dataModel/object-is-null.json | 16 +- .../dataModel/simple-lookup-equals.json | 22 +- .../dataModel/simple-lookup-is-null.json | 16 +- .../dataModel/simple-lookup-is-null2.json | 16 +- .../functions/dataModel/simple-lookup.json | 16 +- .../component-lookup-non-default-model.json | 55 +++ .../component-lookup-non-existant-model.json | 55 +++ .../dataModel-non-default-model.json | 50 +++ .../dataModel-non-existing-model.json | 56 +++ .../language/should-return-nb-if-not-set.json | 0 ...ld-return-profile-settings-preference.json | 0 .../should-return-selected-language.json | 0 .../FullTests/LayoutTestUtils.cs | 85 ++++- .../FullTests/Test1/RunTest1.cs | 7 +- .../FullTests/Test2/RunTest2.cs | 22 +- .../FullTests/Test3/RunTest3.cs | 18 +- .../LayoutExpressions/TestDataModel.cs | 211 ++++++----- .../TestUtilities/DynamicClassBuilder.cs | 153 ++++++++ .../DynamicClassBuilderChatGPTTests.cs | 148 ++++++++ .../TestUtilities/DynamicClassBuilderTests.cs | 58 +++ 85 files changed, 2379 insertions(+), 1459 deletions(-) create mode 100644 src/Altinn.App.Core/Helpers/DataModel/RowRemovalOption.cs delete mode 100644 src/Altinn.App.Core/Helpers/IDataModel.cs create mode 100644 src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs create mode 100644 src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs create mode 100644 src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs create mode 100644 src/Altinn.App.Core/Models/Layout/ModelBinding.cs delete mode 100644 test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/.editorconfig create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json rename test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/{up-for-evaluation => shared-tests}/functions/language/should-return-nb-if-not-set.json (100%) rename test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/{up-for-evaluation => shared-tests}/functions/language/should-return-profile-settings-preference.json (100%) rename test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/{up-for-evaluation => shared-tests}/functions/language/should-return-selected-language.json (100%) create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderTests.cs diff --git a/src/Altinn.App.Api/Controllers/ResourceController.cs b/src/Altinn.App.Api/Controllers/ResourceController.cs index fd658059d..532aca39d 100644 --- a/src/Altinn.App.Api/Controllers/ResourceController.cs +++ b/src/Altinn.App.Api/Controllers/ResourceController.cs @@ -7,6 +7,7 @@ namespace Altinn.App.Api.Controllers; /// /// Controller to handle resources like css, images, javascript included in an app /// +[ApiController] public class ResourceController : ControllerBase { private readonly IAppResources _appResourceService; @@ -169,13 +170,13 @@ public async Task GetFooterLayout(string org, string app) /// /// The application owner short name /// The application name - /// Unique identifier of the model to fetch validations for. + /// Unique identifier of the model to fetch validations for. /// The validation configuration file as json. [HttpGet] - [Route("{org}/{app}/api/validationconfig/{id}")] - public ActionResult GetValidationConfiguration(string org, string app, string id) + [Route("{org}/{app}/api/validationconfig/{dataTypeId}")] + public ActionResult GetValidationConfiguration(string org, string app, string dataTypeId) { - string? validationConfiguration = _appResourceService.GetValidationConfiguration(id); + var validationConfiguration = _appResourceService.GetValidationConfiguration(dataTypeId); return Ok(validationConfiguration); } } diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 65bcb54c9..94f526c9e 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -170,8 +170,9 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); + services.TryAddTransient(); services.TryAddTransient(); - services.TryAddTransient(); + services.TryAddScoped(); services.AddTransient(); services.Configure(configuration.GetSection("PEPSettings")); services.Configure(configuration.GetSection("PlatformSettings")); @@ -204,8 +205,9 @@ IWebHostEnvironment env private static void AddValidationServices(IServiceCollection services, IConfiguration configuration) { + CachedFormDataAccessor.Register(services); services.AddTransient(); - services.TryAddTransient(); + services.AddScoped(); if (configuration.GetSection("AppSettings").Get()?.RequiredValidation == true) { services.AddTransient(); @@ -322,10 +324,10 @@ private static void AddExternalApis(IServiceCollection services) private static void AddProcessServices(IServiceCollection services) { - services.TryAddTransient(); + services.TryAddScoped(); services.TryAddTransient(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddTransient(); services.AddTransient(); services.TryAddTransient(); diff --git a/src/Altinn.App.Core/Features/IFormDataValidator.cs b/src/Altinn.App.Core/Features/IFormDataValidator.cs index bb0e31f51..20f9f44bf 100644 --- a/src/Altinn.App.Core/Features/IFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/IFormDataValidator.cs @@ -1,6 +1,5 @@ using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.Extensions.DependencyInjection; namespace Altinn.App.Core.Features; @@ -11,8 +10,7 @@ namespace Altinn.App.Core.Features; public interface IFormDataValidator { /// - /// The data type this validator is for. Typically either hard coded by implementation or - /// or set by constructor using a and a keyed service. + /// The data type this validator is for. /// /// To validate all types with form data, just use a "*" as value /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 4c95790f5..8b8fca981 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -3,6 +3,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Logging; @@ -19,8 +20,7 @@ public class ExpressionValidator : IFormDataValidator private readonly ILogger _logger; private readonly IAppResources _appResourceService; - private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; - private readonly IAppMetadata _appMetadata; + private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; /// /// Constructor for the expression validator @@ -28,14 +28,12 @@ public class ExpressionValidator : IFormDataValidator public ExpressionValidator( ILogger logger, IAppResources appResourceService, - LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, - IAppMetadata appMetadata + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer ) { _logger = logger; _appResourceService = appResourceService; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; - _appMetadata = appMetadata; } /// @@ -47,7 +45,7 @@ IAppMetadata appMetadata public string ValidationSource => "Expression"; /// - /// Expression validations should always run (it is way to complex to figure out if it should run or not) + /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway /// public bool HasRelevantChanges(object current, object previous) => true; @@ -59,6 +57,9 @@ public async Task> ValidateFormData( string? language ) { + // TODO: Consider not depending on the instance object to get the task + // to follow the same principle as the other validators + var taskId = instance.Process.CurrentTask.ElementId; var rawValidationConfig = _appResourceService.GetValidationConfiguration(dataElement.DataType); if (rawValidationConfig == null) { @@ -67,18 +68,20 @@ public async Task> ValidateFormData( } using var validationConfig = JsonDocument.Parse(rawValidationConfig); - var appMetadata = await _appMetadata.GetApplicationMetadata(); - var layoutSet = _appResourceService.GetLayoutSetForTask( - appMetadata.DataTypes.First(dt => dt.Id == dataElement.DataType).TaskId + + var evaluatorState = await _layoutEvaluatorStateInitializer.Init( + instance, + taskId, + gatewayAction: null, + language ); - var evaluatorState = await _layoutEvaluatorStateInitializer.Init(instance, data, layoutSet?.Id); var hiddenFields = LayoutEvaluator.GetHiddenFieldsForRemoval(evaluatorState, true); var validationIssues = new List(); var expressionValidations = ParseExpressionValidationConfig(validationConfig.RootElement, _logger); foreach (var validationObject in expressionValidations) { - var baseField = validationObject.Key; + var baseField = new ModelBinding { Field = validationObject.Key, DataType = dataElement.DataType }; var resolvedFields = evaluatorState.GetResolvedKeys(baseField); var validations = validationObject.Value; foreach (var resolvedField in resolvedFields) @@ -92,7 +95,7 @@ public async Task> ValidateFormData( rowIndices: DataModel.GetRowIndices(resolvedField), rowLength: null ); - var positionalArguments = new[] { resolvedField }; + var positionalArguments = new object[] { resolvedField }; foreach (var validation in validations) { try @@ -102,29 +105,33 @@ public async Task> ValidateFormData( continue; } - var isInvalid = ExpressionEvaluator.EvaluateExpression( + var validationResult = ExpressionEvaluator.EvaluateExpression( evaluatorState, - validation.Condition, + validation.Condition.Value, context, positionalArguments ); - if (isInvalid is not bool) - { - throw new ArgumentException( - $"Validation condition for {resolvedField} did not evaluate to a boolean" - ); - } - if ((bool)isInvalid) + switch (validationResult) { - var validationIssue = new ValidationIssue - { - Field = resolvedField, - Severity = validation.Severity ?? ValidationIssueSeverity.Error, - CustomTextKey = validation.Message, - Code = validation.Message, - Source = ValidationIssueSources.Expression, - }; - validationIssues.Add(validationIssue); + case true: + var validationIssue = new ValidationIssue + { + Field = resolvedField.Field, + DataElementId = resolvedField.DataType, + Severity = validation.Severity ?? ValidationIssueSeverity.Error, + CustomTextKey = validation.Message, + Code = validation.Message, + Source = ValidationIssueSources.Expression, + }; + validationIssues.Add(validationIssue); + + break; + case false: + break; + default: + throw new ArgumentException( + $"Validation condition for {resolvedField} did not evaluate to a boolean" + ); } } catch (Exception e) @@ -212,7 +219,7 @@ ILogger logger ILogger logger ) { - var rawExpressionValidatıon = new RawExpressionValidation(); + var rawExpressionValidation = new RawExpressionValidation(); if (definition.ValueKind == JsonValueKind.String) { @@ -232,9 +239,9 @@ ILogger logger ); return null; } - rawExpressionValidatıon.Message = reference.Message; - rawExpressionValidatıon.Condition = reference.Condition; - rawExpressionValidatıon.Severity = reference.Severity; + rawExpressionValidation.Message = reference.Message; + rawExpressionValidation.Condition = reference.Condition; + rawExpressionValidation.Severity = reference.Severity; } else { @@ -257,34 +264,34 @@ ILogger logger ); return null; } - rawExpressionValidatıon.Message = reference.Message; - rawExpressionValidatıon.Condition = reference.Condition; - rawExpressionValidatıon.Severity = reference.Severity; + rawExpressionValidation.Message = reference.Message; + rawExpressionValidation.Condition = reference.Condition; + rawExpressionValidation.Severity = reference.Severity; } if (expressionDefinition.Message != null) { - rawExpressionValidatıon.Message = expressionDefinition.Message; + rawExpressionValidation.Message = expressionDefinition.Message; } if (expressionDefinition.Condition != null) { - rawExpressionValidatıon.Condition = expressionDefinition.Condition; + rawExpressionValidation.Condition = expressionDefinition.Condition; } if (expressionDefinition.Severity != null) { - rawExpressionValidatıon.Severity = expressionDefinition.Severity; + rawExpressionValidation.Severity = expressionDefinition.Severity; } } - if (rawExpressionValidatıon.Message == null) + if (rawExpressionValidation.Message == null) { logger.LogError("Validation for field {field} is missing message", field); return null; } - if (rawExpressionValidatıon.Condition == null) + if (rawExpressionValidation.Condition == null) { logger.LogError("Validation for field {field} is missing condition", field); return null; @@ -292,9 +299,9 @@ ILogger logger var expressionValidation = new ExpressionValidation { - Message = rawExpressionValidatıon.Message, - Condition = rawExpressionValidatıon.Condition, - Severity = rawExpressionValidatıon.Severity ?? ValidationIssueSeverity.Error, + Message = rawExpressionValidation.Message, + Condition = rawExpressionValidation.Condition, + Severity = rawExpressionValidation.Severity ?? ValidationIssueSeverity.Error, }; return expressionValidation; @@ -306,8 +313,10 @@ ILogger logger ) { var expressionValidationDefinitions = new Dictionary(); - JsonElement definitionsObject; - var hasDefinitions = expressionValidationConfig.TryGetProperty("definitions", out definitionsObject); + var hasDefinitions = expressionValidationConfig.TryGetProperty( + "definitions", + out JsonElement definitionsObject + ); if (hasDefinitions) { foreach (var definitionObject in definitionsObject.EnumerateObject()) @@ -329,8 +338,10 @@ ILogger logger } } var expressionValidations = new Dictionary>(); - JsonElement validationsObject; - var hasValidations = expressionValidationConfig.TryGetProperty("validations", out validationsObject); + var hasValidations = expressionValidationConfig.TryGetProperty( + "validations", + out JsonElement validationsObject + ); if (hasValidations) { foreach (var validationArray in validationsObject.EnumerateObject()) diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index e0302a955..22c2db084 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -1,4 +1,3 @@ -using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -10,22 +9,14 @@ namespace Altinn.App.Core.Features.Validation.Default; /// public class RequiredLayoutValidator : IFormDataValidator { - private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; - private readonly IAppResources _appResourcesService; - private readonly IAppMetadata _appMetadata; + private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; /// /// Initializes a new instance of the class. /// - public RequiredLayoutValidator( - LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, - IAppResources appResourcesService, - IAppMetadata appMetadata - ) + public RequiredLayoutValidator(ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) { _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; - _appResourcesService = appResourcesService; - _appMetadata = appMetadata; } /// @@ -39,13 +30,11 @@ IAppMetadata appMetadata public string ValidationSource => "Required"; /// - /// Always run for incremental validation + /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway /// public bool HasRelevantChanges(object current, object previous) => true; - /// - /// Validate the form data against the required rules in the layout - /// + /// public async Task> ValidateFormData( Instance instance, DataElement dataElement, @@ -53,11 +42,15 @@ public async Task> ValidateFormData( string? language ) { - var appMetadata = await _appMetadata.GetApplicationMetadata(); - var layoutSet = _appResourcesService.GetLayoutSetForTask( - appMetadata.DataTypes.First(dt => dt.Id == dataElement.DataType).TaskId + var taskId = instance.Process.CurrentTask.ElementId; + + var evaluationState = await _layoutEvaluatorStateInitializer.Init( + instance, + taskId, + gatewayAction: null, + language ); - var evaluationState = await _layoutEvaluatorStateInitializer.Init(instance, data, layoutSet?.Id); - return LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState, dataElement.Id); + + return LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState); } } diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 361d9098a..06a171ae9 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -1,37 +1,77 @@ +using System.Collections; +using System.Diagnostics; using System.Globalization; using System.Reflection; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Altinn.App.Core.Models.Layout; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Helpers.DataModel; /// /// Get data fields from a model, using string keys (like "Bedrifter[1].Ansatte[1].Alder") /// -public class DataModel : IDataModelAccessor +public class DataModel { - private readonly object _serviceModel; + private readonly object _defaultServiceModel; + private readonly Dictionary _dataModels = []; /// - /// Constructor that wraps a PCOC data model, and gives extra tool for working with the data + /// Constructor that wraps a POCO data model, and gives extra tool for working with the data /// - public DataModel(object serviceModel) + public DataModel(IEnumerable> dataModels) { - _serviceModel = serviceModel; + var count = 0; + foreach (var (dataElement, data) in dataModels) + { + if (count++ == 0) + { + DefaultDataElement = dataElement; + _defaultServiceModel = data; + } + _dataModels.Add(dataElement.DataType, data); + } + Debug.Assert(DefaultDataElement is not null, "DataModel initialized with no data elements"); + Debug.Assert(_defaultServiceModel is not null, "DataModel initialized with no data"); } - /// - public object? GetModelData(string key, ReadOnlySpan indicies = default) + private object? ServiceModel(ModelBinding key) { - return GetModelDataRecursive(key.Split('.'), 0, _serviceModel, indicies); + if (key.DataType == null) + { + return _defaultServiceModel; + } + + if (_dataModels.TryGetValue(key.DataType, out var dataModel)) + { + Debug.Assert(dataModel is not null); + return dataModel; + } + + return null; } - /// - public int? GetModelDataCount(string key, ReadOnlySpan indicies = default) + /// + /// Get model data based on key and optionally indicies + /// + /// + /// Inline indicies in the key "Bedrifter[1].Ansatte[1].Alder" will override + /// normal indicies, and if both "Bedrifter" and "Ansatte" is lists, + /// "Bedrifter[1].Ansatte.Alder", will fail, because the indicies will be reset + /// after an inline index is used + /// + public object? GetModelData(ModelBinding key, ReadOnlySpan indicies = default) { - if ( - GetModelDataRecursive(key.Split('.'), 0, _serviceModel, indicies) - is System.Collections.IEnumerable childEnum - ) + return GetModelDataRecursive(key.Field.Split('.'), 0, ServiceModel(key), indicies); + } + + /// + /// Get the count of data elements set in a group (enumerable) + /// + public int? GetModelDataCount(ModelBinding key, ReadOnlySpan indicies = default) + { + if (GetModelDataRecursive(key.Field.Split('.'), 0, ServiceModel(key), indicies) is IEnumerable childEnum) { int retCount = 0; foreach (var _ in childEnum) @@ -44,15 +84,15 @@ is System.Collections.IEnumerable childEnum return null; } - private object? GetModelDataRecursive(string[] keys, int index, object currentModel, ReadOnlySpan indicies) + private object? GetModelDataRecursive(string[] keys, int index, object? currentModel, ReadOnlySpan indicies) { - if (index == keys.Length) + if (index == keys.Length || currentModel is null) { return currentModel; } var (key, groupIndex) = ParseKeyPart(keys[index]); - var prop = currentModel.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); var childModel = prop?.GetValue(currentModel); if (childModel is null) { @@ -60,8 +100,8 @@ is System.Collections.IEnumerable childEnum } // Strings are enumerable in C# - // Other enumerable types is treated as an collection - if (!(childModel is not string && childModel is System.Collections.IEnumerable childModelList)) + // Other enumerable types is treated as a collection + if (!(childModel is not string && childModel is IEnumerable childModelList)) { return GetModelDataRecursive(keys, index + 1, childModel, indicies); } @@ -94,19 +134,23 @@ is System.Collections.IEnumerable childEnum return GetModelDataRecursive(keys, index + 1, elementAt, indicies.Length > 0 ? indicies.Slice(1) : indicies); } - /// - public string[] GetResolvedKeys(string key) + /// + /// Get an array of all keys in repeating groups that match this key + /// + /// + /// GetResolvedKeys("data.bedrifter.styre.medlemmer") => + /// [ + /// "data.bedrifter[0].styre.medlemmer", + /// "data.bedrifter[1].styre.medlemmer" + /// ] + /// + public ModelBinding[] GetResolvedKeys(ModelBinding key) { - if (_serviceModel is null) - { - return []; - } - - var keyParts = key.Split('.'); - return GetResolvedKeysRecursive(keyParts, _serviceModel); + var keyParts = key.Field.Split('.'); + return GetResolvedKeysRecursive(key, keyParts, ServiceModel(key)); } - internal static string JoinFieldKeyParts(string? currentKey, string? key) + private static string JoinFieldKeyParts(string? currentKey, string? key) { if (String.IsNullOrEmpty(currentKey)) { @@ -114,7 +158,7 @@ internal static string JoinFieldKeyParts(string? currentKey, string? key) } if (String.IsNullOrEmpty(key)) { - return currentKey ?? ""; + return currentKey; } return currentKey + "." + key; @@ -129,16 +173,17 @@ internal static string JoinFieldKeyParts(string? currentKey, string? key) /// /// Get the row indices from a key /// - public static int[]? GetRowIndices(string key) + public static int[]? GetRowIndices(ModelBinding key) { - var match = _rowIndexRegex.Match(key); + var match = _rowIndexRegex.Match(key.Field); var rowIndices = match.Groups[3].Captures.Select(c => c.Value).Select(int.Parse).ToArray(); return rowIndices.Length == 0 ? null : rowIndices; } - private string[] GetResolvedKeysRecursive( + private static ModelBinding[] GetResolvedKeysRecursive( + ModelBinding fullKey, string[] keyParts, - object currentModel, + object? currentModel, int currentIndex = 0, string currentKey = "" ) @@ -150,28 +195,29 @@ private string[] GetResolvedKeysRecursive( if (currentIndex == keyParts.Length) { - return [currentKey]; + return [fullKey with { Field = currentKey }]; } var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]); - var prop = currentModel?.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + var prop = currentModel.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); var childModel = prop?.GetValue(currentModel); if (childModel is null) { return []; } - if (childModel is not string && childModel is System.Collections.IEnumerable childModelList) + if (childModel is not string && childModel is IEnumerable childModelList) { - // childModel is an array + // childModel is a list if (groupIndex is null) { // Index not specified, recurse on all elements int i = 0; - var resolvedKeys = new List(); + var resolvedKeys = new List(); foreach (var child in childModelList) { var newResolvedKeys = GetResolvedKeysRecursive( + fullKey, keyParts, child, currentIndex + 1, @@ -182,25 +228,29 @@ private string[] GetResolvedKeysRecursive( } return resolvedKeys.ToArray(); } - else - { - // Index specified, recurse on that element - return GetResolvedKeysRecursive( - keyParts, - childModel, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") - ); - } + // Index specified, recurse on that element + return GetResolvedKeysRecursive( + fullKey, + keyParts, + childModel, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") + ); } // Otherwise, just recurse - return GetResolvedKeysRecursive(keyParts, childModel, currentIndex + 1, JoinFieldKeyParts(currentKey, key)); + return GetResolvedKeysRecursive( + fullKey, + keyParts, + childModel, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key) + ); } - private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index) + private static object? GetElementAt(IEnumerable enumerable, int index) { - // Return the element with index = groupIndex (could not find anohter way to get the n'th element in non generic enumerable) + // Return the element with index = groupIndex (could not find another way to get the nth element in non-generic enumerable) foreach (var arrayElement in enumerable) { if (index-- < 1) @@ -214,17 +264,17 @@ private string[] GetResolvedKeysRecursive( private static readonly Regex _keyPartRegex = new Regex(@"^([^\s\[\]\.]+)\[(\d+)\]?$"); - internal static (string key, int? index) ParseKeyPart(string keypart) + private static (string key, int? index) ParseKeyPart(string keyPart) { - if (keypart.Length == 0) + if (keyPart.Length == 0) { throw new DataModelException("Tried to parse empty part of dataModel key"); } - if (keypart.Last() != ']') + if (keyPart.Last() != ']') { - return (keypart, null); + return (keyPart, null); } - var match = _keyPartRegex.Match(keypart); + var match = _keyPartRegex.Match(keyPart); return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); } @@ -232,9 +282,7 @@ private static void AddIndiciesRecursive( List ret, Type currentModelType, ReadOnlySpan keys, - string fullKey, - ReadOnlySpan indicies, - ReadOnlySpan originalIndicies + ReadOnlySpan indicies ) { if (keys.Length == 0) @@ -252,12 +300,8 @@ ReadOnlySpan originalIndicies var childType = prop.PropertyType; // Strings are enumerable in C# - // Other enumerable types is treated as an collection - if ( - childType != typeof(string) - && childType.IsAssignableTo(typeof(System.Collections.IEnumerable)) - && currentIndex is not null - ) + // Other enumerable types is treated as a collection + if (childType != typeof(string) && childType.IsAssignableTo(typeof(IEnumerable)) && currentIndex is not null) { // Hope the first generic argument is tied to the IEnumerable implementation var childTypeEnumerableParameter = childType.GetGenericArguments().FirstOrDefault(); @@ -273,7 +317,7 @@ ReadOnlySpan originalIndicies indicies = indicies.Slice(1); } - AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), fullKey, indicies, originalIndicies); + AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), indicies); } else { @@ -283,21 +327,37 @@ ReadOnlySpan originalIndicies } ret.Add(key); - AddIndiciesRecursive(ret, childType, keys.Slice(1), fullKey, indicies, originalIndicies); + AddIndiciesRecursive(ret, childType, keys.Slice(1), indicies); } } - /// - public string AddIndicies(string key, ReadOnlySpan indicies = default) + /// + /// Return a full dataModelBiding from a context aware binding by adding indicies + /// + /// + /// key = "bedrift.ansatte.navn" + /// indicies = [1,2] + /// => "bedrift[1].ansatte[2].navn" + /// + public ModelBinding AddIndicies(ModelBinding key, ReadOnlySpan indicies = default) { if (indicies.Length == 0) { - return key; + return key with { DataType = key.DataType ?? DefaultDataElement.DataType }; + } + var serviceModel = ServiceModel(key); + if (serviceModel is null) + { + throw new DataModelException("Could not find service model for dataType " + key.DataType); } var ret = new List(); - AddIndiciesRecursive(ret, this._serviceModel.GetType(), key.Split('.'), key, indicies, indicies); - return string.Join('.', ret); + AddIndiciesRecursive(ret, serviceModel.GetType(), key.Field.Split('.'), indicies); + return new ModelBinding + { + Field = string.Join('.', ret), + DataType = key.DataType ?? DefaultDataElement.DataType + }; } private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key) @@ -305,29 +365,27 @@ private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key var ca = propertyInfo.CustomAttributes; // Read [JsonPropertyName("propName")] from System.Text.Json - var system_text_json_attribute = ( - ca.FirstOrDefault(attr => - attr.AttributeType == typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute) - ) + if ( + ca.FirstOrDefault(attr => attr.AttributeType == typeof(JsonPropertyNameAttribute)) ?.ConstructorArguments.FirstOrDefault() - .Value as string - ); - if (system_text_json_attribute is not null) + .Value + is string systemTextJsonAttribute + ) { - return system_text_json_attribute == key; + return systemTextJsonAttribute == key; } // Read [JsonProperty("propName")] from Newtonsoft.Json - var newtonsoft_json_attribute = ( - ca.FirstOrDefault(attr => attr.AttributeType == typeof(Newtonsoft.Json.JsonPropertyAttribute)) - ?.ConstructorArguments.FirstOrDefault() - .Value as string - ); // To remove dependency on Newtonsoft, while keeping compatibility // var newtonsoft_json_attribute = (ca.FirstOrDefault(attr => attr.AttributeType.FullName == "Newtonsoft.Json.JsonPropertyAttribute")?.ConstructorArguments.FirstOrDefault().Value as string); - if (newtonsoft_json_attribute is not null) + if ( + ca.FirstOrDefault(attr => attr.AttributeType == typeof(Newtonsoft.Json.JsonPropertyAttribute)) + ?.ConstructorArguments.FirstOrDefault() + .Value + is string newtonsoftJsonAttribute + ) { - return newtonsoft_json_attribute == key; + return newtonsoftJsonAttribute == key; } // Fallback to property name if all attributes could not be found @@ -335,21 +393,23 @@ .Value as string return keyName == key; } - /// - public void RemoveField(string key, RowRemovalOption rowRemovalOption) + /// + /// Set the value of a field in the model to default (null) + /// + public void RemoveField(ModelBinding key, RowRemovalOption rowRemovalOption) { - var keys_split = key.Split('.'); - var keys = keys_split[0..^1]; - var (lastKey, lastGroupIndex) = ParseKeyPart(keys_split[^1]); + var keysSplit = key.Field.Split('.'); + var keys = keysSplit[0..^1]; + var (lastKey, lastGroupIndex) = ParseKeyPart(keysSplit[^1]); - var containingObject = GetModelDataRecursive(keys, 0, _serviceModel, default); + var containingObject = GetModelDataRecursive(keys, 0, ServiceModel(key), default); if (containingObject is null) { // Already empty field return; } - if (containingObject is System.Collections.IEnumerable) + if (containingObject is IEnumerable) { throw new NotImplementedException($"Tried to remove field {key}, ended in an enumerable"); } @@ -367,7 +427,7 @@ public void RemoveField(string key, RowRemovalOption rowRemovalOption) { // Remove row from list var propertyValue = property.GetValue(containingObject); - if (propertyValue is not System.Collections.IList listValue) + if (propertyValue is not IList listValue) { throw new ArgumentException( $"Tried to remove row {key}, ended in a non-list ({propertyValue?.GetType()})" @@ -398,12 +458,24 @@ public void RemoveField(string key, RowRemovalOption rowRemovalOption) } } - /// - public bool VerifyKey(string key) + /// + /// Verify that a key is valid for the model + /// + public bool VerifyKey(ModelBinding key) { - return VerifyKeyRecursive(key.Split('.'), 0, _serviceModel.GetType()); + var serviceModel = ServiceModel(key); + if (serviceModel is null) + { + return false; + } + return VerifyKeyRecursive(key.Field.Split('.'), 0, serviceModel.GetType()); } + /// + /// The default data element when is not set + /// + public DataElement DefaultDataElement { get; } + private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) { if (index == keys.Length) @@ -425,8 +497,8 @@ private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) var childType = prop.PropertyType; // Strings are enumerable in C# - // Other enumerable types is treated as an collection - if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) + // Other enumerable types is treated as a collection + if (childType != typeof(string) && childType.IsAssignableTo(typeof(IEnumerable))) { var childTypeEnumerableParameter = childType .GetInterfaces() diff --git a/src/Altinn.App.Core/Helpers/DataModel/RowRemovalOption.cs b/src/Altinn.App.Core/Helpers/DataModel/RowRemovalOption.cs new file mode 100644 index 000000000..c68134b0f --- /dev/null +++ b/src/Altinn.App.Core/Helpers/DataModel/RowRemovalOption.cs @@ -0,0 +1,22 @@ +namespace Altinn.App.Core.Helpers.DataModel; + +/// +/// Option for how to handle row removal +/// +public enum RowRemovalOption +{ + /// + /// Remove the row from the data model + /// + DeleteRow, + + /// + /// Set the row to null, used to preserve row indices + /// + SetToNull, + + /// + /// Ignore row removal + /// + Ignore +} diff --git a/src/Altinn.App.Core/Helpers/IDataModel.cs b/src/Altinn.App.Core/Helpers/IDataModel.cs deleted file mode 100644 index 9339ee176..000000000 --- a/src/Altinn.App.Core/Helpers/IDataModel.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace Altinn.App.Core.Helpers; - -/// -/// Interface for accessing fields in the data model -/// -public interface IDataModelAccessor -{ - /// - /// Get model data based on key and optionally indicies - /// - /// - /// Inline indicies in the key "Bedrifter[1].Ansatte[1].Alder" will override - /// normal indicies, and if both "Bedrifter" and "Ansatte" is lists, - /// "Bedrifter[1].Ansatte.Alder", will fail, because the indicies will be reset - /// after an inline index is used - /// - object? GetModelData(string key, ReadOnlySpan indicies = default); - - /// - /// Get the count of data elements set in a group (enumerable) - /// - int? GetModelDataCount(string key, ReadOnlySpan indicies = default); - - /// - /// Get all of the resolved keys (including all possible indexes) from a data model key - /// - string[] GetResolvedKeys(string key); - - /// - /// Return a full dataModelBiding from a context aware binding by adding indicies - /// - /// - /// key = "bedrift.ansatte.navn" - /// indicies = [1,2] - /// => "bedrift[1].ansatte[2].navn" - /// - string AddIndicies(string key, ReadOnlySpan indicies = default); - - /// - /// Remove a value from the wrapped datamodel - /// - void RemoveField(string key, RowRemovalOption rowRemovalOption); - - /// - /// Verify that a Key is a valid lookup for the datamodel - /// - bool VerifyKey(string key); -} - -/// -/// Option for how to handle row removal -/// -public enum RowRemovalOption -{ - /// - /// Remove the row from the data model - /// - DeleteRow, - - /// - /// Set the row to null, used to preserve row indices - /// - SetToNull, - - /// - /// Ignore row removal - /// - Ignore -} diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 5adb31c4f..dcae8bf51 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -30,7 +30,6 @@ public class AppResourcesSI : IAppResources private readonly AppSettings _settings; private readonly IAppMetadata _appMetadata; - private readonly IWebHostEnvironment _hostingEnvironment; private readonly ILogger _logger; private readonly Telemetry? _telemetry; @@ -52,7 +51,6 @@ public AppResourcesSI( { _settings = settings.Value; _appMetadata = appMetadata; - _hostingEnvironment = hostingEnvironment; _logger = logger; _telemetry = telemetry; } @@ -81,17 +79,15 @@ public byte[] GetText(string org, string app, string textResource) return null; } - using (FileStream fileStream = new(fullFileName, FileMode.Open, FileAccess.Read)) - { - TextResource textResource = - await System.Text.Json.JsonSerializer.DeserializeAsync(fileStream, _jsonSerializerOptions) - ?? throw new System.Text.Json.JsonException("Failed to deserialize text resource"); - textResource.Id = $"{org}-{app}-{language}"; - textResource.Org = org; - textResource.Language = language; - - return textResource; - } + await using FileStream fileStream = new(fullFileName, FileMode.Open, FileAccess.Read); + TextResource textResource = + await System.Text.Json.JsonSerializer.DeserializeAsync(fileStream, _jsonSerializerOptions) + ?? throw new System.Text.Json.JsonException("Failed to deserialize text resource"); + textResource.Id = $"{org}-{app}-{language}"; + textResource.Org = org; + textResource.Language = language; + + return textResource; } /// @@ -286,7 +282,7 @@ public string GetLayoutSets() { using var activity = _telemetry?.StartGetLayoutSetsForTaskActivity(); var sets = GetLayoutSet(); - return sets?.Sets?.FirstOrDefault(s => s?.Tasks?.Contains(taskId) ?? false); + return sets?.Sets?.Find(s => s?.Tasks?.Contains(taskId) ?? false); } /// @@ -296,6 +292,9 @@ public string GetLayoutsForSet(string layoutSetId) Dictionary layouts = new Dictionary(); string layoutsPath = _settings.AppBasePath + _settings.UiFolder + layoutSetId + "/layouts/"; + + PathHelper.EnsureLegalPath(Path.Join(_settings.AppBasePath, _settings.UiFolder), layoutsPath); + if (Directory.Exists(layoutsPath)) { foreach (string file in Directory.GetFiles(layoutsPath)) @@ -311,32 +310,49 @@ public string GetLayoutsForSet(string layoutSetId) } /// + [Obsolete("Use GetLayoutModelForTask instead", true)] public LayoutModel GetLayoutModel(string? layoutSetId = null) + { + throw new NotImplementedException(); + } + + /// + public LayoutModel GetLayoutModelForTask(string taskId) { using var activity = _telemetry?.StartGetLayoutModelActivity(); - string folder = Path.Join(_settings.AppBasePath, _settings.UiFolder, layoutSetId, "layouts"); - var order = GetLayoutSettingsForSet(layoutSetId)?.Pages?.Order; - if (order is null) - { - throw new InvalidDataException( - "No $Pages.Order field found" + (layoutSetId is null ? "" : $" for layoutSet {layoutSetId}") + var layoutSet = GetLayoutSetForTask(taskId); + var order = + GetLayoutSettingsForSet(layoutSet?.Id)?.Pages?.Order + ?? throw new InvalidDataException( + "No $Pages.Order field found" + (layoutSet?.Id is null ? "" : $" for layoutSet {layoutSet.Id}") ); - } - var layoutModel = new LayoutModel(); + var pages = new Dictionary(); + string folder = Path.Join(_settings.AppBasePath, _settings.UiFolder, layoutSet?.Id, "layouts"); foreach (var page in order) { var pageBytes = File.ReadAllBytes(Path.Join(folder, page + ".json")); // Set the PageName using AsyncLocal before deserializing. PageComponentConverter.SetAsyncLocalPageName(page); - layoutModel.Pages[page] = + pages[page] = System.Text.Json.JsonSerializer.Deserialize( pageBytes.RemoveBom(), _jsonSerializerOptions ) ?? throw new InvalidDataException(page + ".json is \"null\""); } - return layoutModel; + return new LayoutModel() { DefaultDataType = GetDefaultDataType(taskId, layoutSet), Pages = pages, }; + } + + private DataType GetDefaultDataType(string taskId, LayoutSet? layoutSet) + { + var appMetadata = _appMetadata.GetApplicationMetadata().Result; + // First look for the layoutSet.DataType, then look for the first DataType with a classRef + return appMetadata.DataTypes.Find(d => layoutSet?.DataType == d.Id) + ?? appMetadata.DataTypes.Find(d => d.AppLogic?.ClassRef is not null && d.TaskId == taskId) + ?? throw new InvalidOperationException( + $"No data type found for task {taskId} and layoutSet {layoutSet?.Id}" + ); } /// @@ -349,6 +365,9 @@ public LayoutModel GetLayoutModel(string? layoutSetId = null) layoutSetId, _settings.FormLayoutSettingsFileName ); + + PathHelper.EnsureLegalPath(Path.Join(_settings.AppBasePath, _settings.UiFolder), filename); + string? filedata = null; if (File.Exists(filename)) { @@ -368,11 +387,13 @@ public LayoutModel GetLayoutModel(string? layoutSetId = null) layoutSetId, _settings.FormLayoutSettingsFileName ); + + PathHelper.EnsureLegalPath(Path.Join(_settings.AppBasePath, _settings.UiFolder), filename); + if (File.Exists(filename)) { - string? filedata = null; - filedata = File.ReadAllText(filename, Encoding.UTF8); - LayoutSettings? layoutSettings = JsonConvert.DeserializeObject(filedata); + var fileData = File.ReadAllText(filename, Encoding.UTF8); + LayoutSettings? layoutSettings = JsonConvert.DeserializeObject(fileData); return layoutSettings; } @@ -449,11 +470,11 @@ private byte[] ReadFileContentsFromLegalPath(string legalPath, string filePath) } /// - public string? GetValidationConfiguration(string modelId) + public string? GetValidationConfiguration(string dataTypeId) { using var activity = _telemetry?.StartGetValidationConfigurationActivity(); string legalPath = $"{_settings.AppBasePath}{_settings.ModelsFolder}"; - string filename = $"{legalPath}{modelId}.{_settings.ValidationConfigurationFileName}"; + string filename = $"{legalPath}{dataTypeId}.{_settings.ValidationConfigurationFileName}"; PathHelper.EnsureLegalPath(legalPath, filename); string? filedata = null; diff --git a/src/Altinn.App.Core/Internal/App/IAppResources.cs b/src/Altinn.App.Core/Internal/App/IAppResources.cs index ca249d4dd..dc50f69d6 100644 --- a/src/Altinn.App.Core/Internal/App/IAppResources.cs +++ b/src/Altinn.App.Core/Internal/App/IAppResources.cs @@ -125,9 +125,15 @@ public interface IAppResources /// A dictionary of FormLayout objects serialized to JSON string GetLayoutsForSet(string layoutSetId); + /// + /// Gets the full layout model for the task + /// + LayoutModel GetLayoutModelForTask(string taskId); + /// /// Gets the full layout model for the optional set /// + [Obsolete("Use GetLayoutModelForTask instead", true)] LayoutModel GetLayoutModel(string? layoutSetId = null); /// @@ -156,8 +162,7 @@ public interface IAppResources byte[] GetRuleHandlerForSet(string id); /// - /// Gets the the rule handler for a layoutset + /// Gets the validation configuration for a given data type /// - /// The layout settings - string? GetValidationConfiguration(string modelId); + string? GetValidationConfiguration(string dataTypeId); } diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs new file mode 100644 index 000000000..61d472aa4 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -0,0 +1,149 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Internal.Data; + +/// +/// Class that caches form data to avoid multiple calls to the data service for a single validation +/// +/// Must be registered as a scoped service in DI container +/// +internal sealed class CachedFormDataAccessor : ICachedFormDataAccessor +{ + private readonly IDataClient _dataClient; + private readonly IAppMetadata _appMetadata; + private readonly IAppModel _appModel; + private readonly IHttpContextAccessor _contextAccessor; + private readonly string _requestIdentifier; + private readonly LazyCache _cache = new(); + + public CachedFormDataAccessor( + IDataClient dataClient, + IAppMetadata appMetadata, + IAppModel appModel, + IHttpContextAccessor contextAccessor + ) + { + _dataClient = dataClient; + _appMetadata = appMetadata; + _appModel = appModel; + _contextAccessor = contextAccessor; + ArgumentNullException.ThrowIfNull(_contextAccessor.HttpContext); + _requestIdentifier = _contextAccessor.HttpContext.TraceIdentifier; + } + + /// + public async Task Get(Instance instance, DataElement dataElement) + { + // Be completly sure that the cache is only used in a single http request + if (_requestIdentifier != _contextAccessor.HttpContext?.TraceIdentifier) + { + throw new Exception("Cache can only be used in a single http request"); + } + + return await _cache.GetOrCreate( + dataElement.Id, + async _ => + { + var appMetadata = await _appMetadata.GetApplicationMetadata(); + var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType == null) + { + throw new InvalidOperationException($"Data type {dataElement.DataType} not found in app metadata"); + } + + if (dataType.AppLogic?.ClassRef != null) + { + return await GetFormData(instance, dataElement, dataType); + } + + return await GetBinaryData(instance, dataElement); + } + ); + } + + /// + public void Set(DataElement dataElement, object data) + { + _cache.Set(dataElement.Id, data); + } + + /// + /// Simple wrapper around a ConcurrentDictionary using Lazy to ensure that the valueFactory is only called once + /// + /// The type of the key in the cache + /// The type of the object to cache + private sealed class LazyCache + where TKey : notnull + where TValue : notnull + { + private readonly ConcurrentDictionary>> _cache = new(); + + public async Task GetOrCreate(TKey key, Func> valueFactory) + { + return await _cache.GetOrAdd(key, innerKey => new Lazy>(() => valueFactory(innerKey))).Value; + } + + public void Set(TKey key, TValue data) + { + if (!_cache.TryAdd(key, new Lazy>(Task.FromResult(data)))) + { + var existing = _cache[key]; + if ( + existing.IsValueCreated + && existing.Value.IsCompletedSuccessfully + && data.Equals(existing.Value.Result) + ) + { + // We are trying to set the same value again, so we can just ignore this + return; + } + + throw new InvalidOperationException($"Key {key} already exists in cache"); + } + } + } + + private async Task GetBinaryData(Instance instance, DataElement dataElement) + { + var instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + var app = instance.AppId.Split("/")[1]; + var instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + var data = await _dataClient.GetBinaryData( + instance.Org, + app, + instanceOwnerPartyId, + instanceGuid, + Guid.Parse(dataElement.Id) + ); + return data; + } + + private async Task GetFormData(Instance instance, DataElement dataElement, DataType dataType) + { + var modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); + + var instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + var app = instance.AppId.Split("/")[1]; + var instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + var data = await _dataClient.GetFormData( + instanceGuid, + modelType, + instance.Org, + app, + instanceOwnerPartyId, + Guid.Parse(dataElement.Id) + ); + return data; + } + + internal static void Register(IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs new file mode 100644 index 000000000..c2a9d09d4 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs @@ -0,0 +1,21 @@ +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Internal.Data; + +/// +/// Use this in your validators, dataProcessors to get form data from the cache +/// +/// Note that this is a scoped service and can't be used in singleton or transient services +/// +public interface ICachedFormDataAccessor +{ + /// + /// Get the deserialized data for a given data element + /// + Task Get(Instance instance, DataElement dataElement); + + /// + /// In PATCH requests we need to use the new object for the uploaded data element, instead of fetching from + /// + void Set(DataElement dataElement, object data); +} diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 1ec0979d4..5b4755756 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.RegularExpressions; using Altinn.App.Core.Models.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; namespace Altinn.App.Core.Internal.Expressions; @@ -22,18 +23,15 @@ bool defaultReturn { try { + ArgumentNullException.ThrowIfNull(context.Component); var expr = property switch { - "hidden" => context.Component?.Hidden, - "hiddenRow" - => context.Component is RepeatingGroupComponent repeatingGroup ? repeatingGroup.HiddenRow : null, - "required" => context.Component?.Required, + "hidden" => context.Component.Hidden, + "hiddenRow" when context.Component is RepeatingGroupComponent repeatingGroup + => repeatingGroup.HiddenRow, + "required" => context.Component.Required, _ => throw new ExpressionEvaluatorTypeErrorException($"unknown boolean expression property {property}") }; - if (expr is null) - { - return defaultReturn; - } return EvaluateExpression(state, expr, context) switch { @@ -62,11 +60,7 @@ bool defaultReturn object[]? positionalArguments = null ) { - if (expr is null) - { - return null; - } - if (expr.Function is null || expr.Args is null) + if (!expr.IsFunctionExpression) { return expr.Value; } @@ -75,7 +69,7 @@ bool defaultReturn // ! TODO: should find better ways to deal with nulls here for the next major version var ret = expr.Function switch { - ExpressionFunction.dataModel => DataModel(args.First()?.ToString(), context, state), + ExpressionFunction.dataModel => DataModel(args, context, state), ExpressionFunction.component => Component(args, context, state), ExpressionFunction.instanceContext => state.GetInstanceContext(args.First()?.ToString()!), ExpressionFunction.@if => IfImpl(args), @@ -101,12 +95,29 @@ bool defaultReturn ExpressionFunction.lowerCase => LowerCase(args), ExpressionFunction.argv => Argv(args, positionalArguments), ExpressionFunction.gatewayAction => state.GetGatewayAction(), + ExpressionFunction.language => state.GetLanguage() ?? "nb", _ => throw new ExpressionEvaluatorTypeErrorException($"Function \"{expr.Function}\" not implemented"), }; return ret; } - private static object? DataModel(string? key, ComponentContext? context, LayoutEvaluatorState state) + private static object? DataModel(object?[] args, ComponentContext? context, LayoutEvaluatorState state) + { + var key = args switch + { + [string field] => new ModelBinding { Field = field, DataType = state.DefaultDataElement.DataType }, + [string field, string dataType] => new ModelBinding { Field = field, DataType = dataType }, + [ModelBinding binding] => binding, + [null] => throw new ExpressionEvaluatorTypeErrorException("Cannot lookup dataModel null"), + _ + => throw new ExpressionEvaluatorTypeErrorException( + $"""Expected ["dataModel", ...] to have 1-2 argument(s), got {args.Length}""" + ) + }; + return DataModel(key, context, state); + } + + private static object? DataModel(ModelBinding key, ComponentContext? context, LayoutEvaluatorState state) { var data = state.GetModelData(key, context); diff --git a/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs new file mode 100644 index 000000000..cc1b3f06a --- /dev/null +++ b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs @@ -0,0 +1,21 @@ +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Internal.Expressions; + +/// +/// Interface for initializing a from dependency injection services +/// +public interface ILayoutEvaluatorStateInitializer +{ + /// + /// Initialize a with the given and task id and optional gateway action and language + /// + /// The remaining data will be fetched from dependency injection services + /// + Task Init( + Instance instance, + string taskId, + string? gatewayAction = null, + string? language = null + ); +} diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index d40942805..ab613598f 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -1,5 +1,6 @@ -using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Validation; @@ -13,13 +14,13 @@ public static class LayoutEvaluator /// /// Get a list of fields that are only referenced in hidden components in /// - public static List GetHiddenFieldsForRemoval( + public static List GetHiddenFieldsForRemoval( LayoutEvaluatorState state, bool includeHiddenRowChildren = false ) { - var hiddenModelBindings = new HashSet(); - var nonHiddenModelBindings = new HashSet(); + var hiddenModelBindings = new HashSet(); + var nonHiddenModelBindings = new HashSet(); foreach (var context in state.GetComponentContexts()) { @@ -40,8 +41,8 @@ public static List GetHiddenFieldsForRemoval( private static void HiddenFieldsForRemovalRecurs( LayoutEvaluatorState state, bool includeHiddenRowChildren, - HashSet hiddenModelBindings, - HashSet nonHiddenModelBindings, + HashSet hiddenModelBindings, + HashSet nonHiddenModelBindings, ComponentContext context ) { @@ -129,16 +130,13 @@ public static void RemoveHiddenData(LayoutEvaluatorState state, RowRemovalOption /// /// Return a list of for the given state and dataElementId /// - public static List RunLayoutValidationsForRequired( - LayoutEvaluatorState state, - string dataElementId - ) + public static List RunLayoutValidationsForRequired(LayoutEvaluatorState state) { var validationIssues = new List(); foreach (var context in state.GetComponentContexts()) { - RunLayoutValidationsForRequiredRecurs(validationIssues, state, dataElementId, context); + RunLayoutValidationsForRequiredRecurs(validationIssues, state, context); } return validationIssues; @@ -147,7 +145,6 @@ string dataElementId private static void RunLayoutValidationsForRequiredRecurs( List validationIssues, LayoutEvaluatorState state, - string dataElementId, ComponentContext context ) { @@ -155,7 +152,7 @@ ComponentContext context { foreach (var childContext in context.ChildContexts) { - RunLayoutValidationsForRequiredRecurs(validationIssues, state, dataElementId, childContext); + RunLayoutValidationsForRequiredRecurs(validationIssues, state, childContext); } var required = ExpressionEvaluator.EvaluateBooleanExpression(state, context, "required", false); @@ -170,9 +167,9 @@ ComponentContext context new ValidationIssue() { Severity = ValidationIssueSeverity.Error, - DataElementId = dataElementId, - Field = field, - Description = $"{field} is required in component with id {context.Component.Id}", + DataElementId = state.GetDataElement(field)?.Id, + Field = field.Field, + Description = $"{field.Field} is required in component with id {context.Component.Id}", Code = "required", Source = ValidationIssueSources.Required } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 20415f568..f62dc46f3 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,5 +1,5 @@ using Altinn.App.Core.Configuration; -using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -12,22 +12,24 @@ namespace Altinn.App.Core.Internal.Expressions; /// public class LayoutEvaluatorState { - private readonly IDataModelAccessor _dataModel; + private readonly DataModel _dataModel; private readonly LayoutModel _componentModel; private readonly FrontEndSettings _frontEndSettings; private readonly Instance _instanceContext; private readonly string? _gatewayAction; + private readonly string? _language; private readonly ComponentContext[]? _pageContexts; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. /// public LayoutEvaluatorState( - IDataModelAccessor dataModel, + DataModel dataModel, LayoutModel componentModel, FrontEndSettings frontEndSettings, Instance instance, - string? gatewayAction = null + string? gatewayAction = null, + string? language = null ) { _dataModel = dataModel; @@ -35,6 +37,7 @@ public LayoutEvaluatorState( _frontEndSettings = frontEndSettings; _instanceContext = instance; _gatewayAction = gatewayAction; + _language = language; if (dataModel is not null && componentModel is not null) { @@ -43,6 +46,11 @@ public LayoutEvaluatorState( } } + /// + /// Get the default data element for the layout when is null + /// + public DataElement DefaultDataElement => _dataModel.DefaultDataElement; + /// /// Get a hierarchy of the different contexts in the component model (remember to iterate ) /// @@ -55,25 +63,22 @@ public IEnumerable GetComponentContexts() return _pageContexts; } - private static ComponentContext[] GenerateComponentContexts( - IDataModelAccessor dataModel, - LayoutModel componentModel - ) + private static ComponentContext[] GenerateComponentContexts(DataModel dataModel, LayoutModel componentModel) { return componentModel.Pages.Values.Select(((page) => GeneratePageContext(page, dataModel))).ToArray(); } - private static ComponentContext GeneratePageContext(PageComponent page, IDataModelAccessor dataModel) => + private static ComponentContext GeneratePageContext(PageComponent page, DataModel dataModel) => new ComponentContext( page, null, null, - page.Children.Select(c => GenerateComponentContextsRecurs(c, dataModel, Array.Empty())).ToArray() + page.Children.Select(c => GenerateComponentContextsRecurs(c, dataModel, [])).ToArray() ); private static ComponentContext GenerateComponentContextsRecurs( BaseComponent component, - IDataModelAccessor dataModel, + DataModel dataModel, ReadOnlySpan indexes ) { @@ -123,6 +128,11 @@ ReadOnlySpan indexes return _frontEndSettings.TryGetValue(key, out var setting) ? setting : null; } + /// + /// Gets the current language of the instance viewer + /// + public string? GetLanguage() => _language; + /// /// Get component from componentModel /// @@ -146,9 +156,9 @@ public ComponentContext GetComponentContext(string pageName, string componentId, { throw new ArgumentException($"Unknown page name {pageName}"); } - // Find all decendent contexts that matches componentId and all the given rowIndicies + // Find all descendant contexts that matches componentId and all the given rowIndicies var matches = pageContext - .Decendants.Where(context => + .Descendants.Where(context => context.Component?.Id == componentId && ( context.RowIndices?.Zip(rowIndicies ?? Enumerable.Empty()).All((i) => i.First == i.Second) @@ -171,7 +181,7 @@ public ComponentContext GetComponentContext(string pageName, string componentId, // Find all decendent contexts that matches componentId and all the given rowIndicies matches = _pageContexts .SelectMany(p => - p.Decendants.Where(context => + p.Descendants.Where(context => context.Component?.Id == componentId && ( context.RowIndices?.Zip(rowIndicies ?? Enumerable.Empty()).All((i) => i.First == i.Second) @@ -192,20 +202,15 @@ public ComponentContext GetComponentContext(string pageName, string componentId, /// /// Get field from dataModel with key and context /// - public object? GetModelData(string? key, ComponentContext? context = null) + public object? GetModelData(ModelBinding key, ComponentContext? context = null) { - if (key is null) - { - throw new ArgumentException("Cannot lookup dataModel null"); - } - return _dataModel.GetModelData(key, context?.RowIndices); } /// /// Get all of the resolved keys (including all possible indexes) from a data model key /// - public string[] GetResolvedKeys(string key) + public ModelBinding[] GetResolvedKeys(ModelBinding key) { return _dataModel.GetResolvedKeys(key); } @@ -213,7 +218,7 @@ public string[] GetResolvedKeys(string key) /// /// Set the value of a field to null. /// - public void RemoveDataField(string key, RowRemovalOption rowRemovalOption) + public void RemoveDataField(ModelBinding key, RowRemovalOption rowRemovalOption) { _dataModel.RemoveField(key, rowRemovalOption); } @@ -260,7 +265,7 @@ public string GetInstanceContext(string key) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public string AddInidicies(string binding, ComponentContext context) + public ModelBinding AddInidicies(ModelBinding binding, ComponentContext context) { return _dataModel.AddIndicies(binding, context.RowIndices); } @@ -268,7 +273,7 @@ public string AddInidicies(string binding, ComponentContext context) /// /// Return a full dataModelBiding from a context aware binding by adding indicies /// - public string AddInidicies(string binding, ReadOnlySpan indices) + public ModelBinding AddInidicies(ModelBinding binding, ReadOnlySpan indices) { return _dataModel.AddIndicies(binding, indices); } @@ -295,9 +300,9 @@ public List GetModelErrors() return errors; } - private void GetModelErrorsForExpression(Expression? expr, BaseComponent component, List errors) + private void GetModelErrorsForExpression(Expression expr, BaseComponent component, List errors) { - if (expr == null || expr.Value != null || expr.Args == null || expr.Function == null) + if (!expr.IsFunctionExpression) { return; } @@ -311,7 +316,8 @@ private void GetModelErrorsForExpression(Expression? expr, BaseComponent compone ); return; } - if (!_dataModel.VerifyKey(binding)) + var dataType = expr.Args.ElementAtOrDefault(1).Value as string; + if (!_dataModel.VerifyKey(new ModelBinding { Field = binding, DataType = dataType })) { errors.Add($"Invalid binding \"{binding}\" on component {component.PageId}.{component.Id}"); } @@ -341,7 +347,7 @@ private void EvaluateHiddenExpressionRecurs(ComponentContext context, bool paren if ( context.Component is RepeatingGroupComponent repGroup && context.RowLength is not null - && repGroup.HiddenRow is not null + && repGroup.HiddenRow.IsFunctionExpression ) { var hiddenRows = new List(); @@ -370,4 +376,17 @@ context.Component is RepeatingGroupComponent repGroup EvaluateHiddenExpressionRecurs(childContext, hidden || rowIsHidden); } } + + /// + /// Get the data element that this ModelBinding is pointing to + /// + public DataElement? GetDataElement(ModelBinding field) + { + if (field.DataType is null) + { + return DefaultDataElement; + } + var dataElement = _instanceContext.Data.Find(d => d.DataType == field.DataType); + return dataElement; + } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 2b2a9820a..e4217b455 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -1,6 +1,8 @@ +using System.Diagnostics; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -9,25 +11,32 @@ namespace Altinn.App.Core.Internal.Expressions; /// /// Utility class for collecting all the services from DI that are needed to initialize /// -public class LayoutEvaluatorStateInitializer +public class LayoutEvaluatorStateInitializer : ILayoutEvaluatorStateInitializer { // Dependency injection properties (set in ctor) private readonly IAppResources _appResources; private readonly FrontEndSettings _frontEndSettings; + private readonly ICachedFormDataAccessor _dataAccessor; /// /// Constructor with services from dependency injection /// - public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions frontEndSettings) + public LayoutEvaluatorStateInitializer( + IAppResources appResources, + IOptions frontEndSettings, + ICachedFormDataAccessor dataAccessor + ) { _appResources = appResources; + _dataAccessor = dataAccessor; _frontEndSettings = frontEndSettings.Value; } /// /// Initialize LayoutEvaluatorState with given Instance, data object and layoutSetId /// - public virtual Task Init( + [Obsolete("Use the overload with ILayoutEvaluatorStateInitializer instead")] + public Task Init( Instance instance, object data, string? layoutSetId, @@ -35,8 +44,57 @@ public virtual Task Init( ) { var layouts = _appResources.GetLayoutModel(layoutSetId); + var dataElement = instance.Data.Find(d => d.DataType == layouts.DefaultDataType.Id); + Debug.Assert(dataElement is not null); return Task.FromResult( - new LayoutEvaluatorState(new DataModel(data), layouts, _frontEndSettings, instance, gatewayAction) + new LayoutEvaluatorState( + new DataModel([KeyValuePair.Create(dataElement, data)]), + layouts, + _frontEndSettings, + instance, + gatewayAction + ) + ); + } + + /// + public async Task Init( + Instance instance, + string taskId, + string? gatewayAction = null, + string? language = null + ) + { + var layouts = _appResources.GetLayoutModelForTask(taskId); + + var defaultDataTypeId = layouts.DefaultDataType.Id; + var defaultDataElement = instance.Data.Find(d => d.DataType == defaultDataTypeId); + if (defaultDataElement is null) + { + throw new InvalidOperationException($"No data element found for data type {defaultDataTypeId}"); + } + + var dataTasks = new List>>(); + foreach (var dataType in layouts.GetReferencedDataTypeIds()) + { + dataTasks.AddRange( + instance + .Data.Where(dataElement => dataElement.DataType == dataType) + .Select(async dataElement => + KeyValuePair.Create(dataElement, await _dataAccessor.Get(instance, dataElement)) + ) + ); + } + + var extraModels = await Task.WhenAll(dataTasks); + + return new LayoutEvaluatorState( + new DataModel(extraModels), + layouts, + _frontEndSettings, + instance, + gatewayAction, + language ); } } diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 35bfe198d..5173eaf56 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -2,12 +2,8 @@ using System.Text; using System.Text.Json; using Altinn.App.Core.Features; -using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; -using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Process.Elements; -using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Process; using Altinn.Platform.Storage.Interface.Models; @@ -27,36 +23,15 @@ public class ExpressionsExclusiveGateway : IProcessExclusiveGateway PropertyNameCaseInsensitive = true, }; - private static readonly JsonSerializerOptions _jsonSerializerOptionsCamelCase = - new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - private readonly LayoutEvaluatorStateInitializer _layoutStateInit; - private readonly IAppResources _resources; - private readonly IAppMetadata _appMetadata; - private readonly IDataClient _dataClient; - private readonly IAppModel _appModel; + private readonly ILayoutEvaluatorStateInitializer _layoutStateInit; /// /// Constructor for /// /// Expressions state initalizer used to create context for expression evaluation - /// Service for fetching app resources - /// Service for fetching app model - /// Service for fetching app metadata - /// Service for interacting with Platform Storage - public ExpressionsExclusiveGateway( - LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, - IAppResources resources, - IAppModel appModel, - IAppMetadata appMetadata, - IDataClient dataClient - ) + public ExpressionsExclusiveGateway(ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) { _layoutStateInit = layoutEvaluatorStateInitializer; - _resources = resources; - _appMetadata = appMetadata; - _dataClient = dataClient; - _appModel = appModel; } /// @@ -71,8 +46,9 @@ ProcessGatewayInformation processGatewayInformation { var state = await GetLayoutEvaluatorState( instance, + instance.Process.CurrentTask.ElementId, processGatewayInformation.Action, - processGatewayInformation.DataTypeId + language: null ); return outgoingFlows.Where(outgoingFlow => EvaluateSequenceFlow(state, outgoingFlow)).ToList(); @@ -80,32 +56,12 @@ ProcessGatewayInformation processGatewayInformation private async Task GetLayoutEvaluatorState( Instance instance, - string? action, - string? dataTypeId + string taskId, + string? gatewayAction, + string? language ) { - var layoutSet = GetLayoutSet(instance); - var (checkedDataTypeId, dataType) = await GetDataType(instance, layoutSet, dataTypeId); - object data = new object(); - if (checkedDataTypeId != null && dataType != null) - { - InstanceIdentifier instanceIdentifier = new InstanceIdentifier(instance); - var dataGuid = GetDataId(instance, checkedDataTypeId); - Type dataElementType = dataType; - if (dataGuid != null) - { - data = await _dataClient.GetFormData( - instanceIdentifier.InstanceGuid, - dataElementType, - instance.Org, - instance.AppId.Split("/")[1], - int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture), - dataGuid.Value - ); - } - } - - var state = await _layoutStateInit.Init(instance, data, layoutSetId: layoutSet?.Id, gatewayAction: action); + var state = await _layoutStateInit.Init(instance, taskId, gatewayAction, language); return state; } @@ -121,7 +77,7 @@ private static bool EvaluateSequenceFlow(LayoutEvaluatorState state, SequenceFlo foreach (ComponentContext? componentContext in stateComponentContexts) { var result = ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); - if (result is bool boolResult && boolResult) + if (result is true) { return true; } @@ -139,71 +95,7 @@ private static Expression GetExpressionFromCondition(string condition) { Utf8JsonReader reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(condition)); reader.Read(); - var expressionFromCondition = ExpressionConverter.ReadNotNull(ref reader, _jsonSerializerOptions); + var expressionFromCondition = ExpressionConverter.ReadStatic(ref reader, _jsonSerializerOptions); return expressionFromCondition; } - - private LayoutSet? GetLayoutSet(Instance instance) - { - string taskId = instance.Process.CurrentTask.ElementId; - - string layoutSetsString = _resources.GetLayoutSets(); - LayoutSet? layoutSet = null; - if (!string.IsNullOrEmpty(layoutSetsString)) - { - LayoutSets? layoutSets = JsonSerializer.Deserialize( - layoutSetsString, - _jsonSerializerOptionsCamelCase - ); - layoutSet = layoutSets?.Sets?.Find(t => t.Tasks.Contains(taskId)); - } - - return layoutSet; - } - - //TODO: Find a better home for this method - private async Task<(string? DataTypeId, Type? DataTypeClassType)> GetDataType( - Instance instance, - LayoutSet? layoutSet, - string? dataTypeId - ) - { - DataType? dataType; - if (dataTypeId != null) - { - dataType = (await _appMetadata.GetApplicationMetadata()).DataTypes.Find(d => - d.Id == dataTypeId && d.AppLogic != null - ); - } - else if (layoutSet != null) - { - dataType = (await _appMetadata.GetApplicationMetadata()).DataTypes.Find(d => - d.Id == layoutSet.DataType && d.AppLogic != null - ); - } - else - { - dataType = (await _appMetadata.GetApplicationMetadata()).DataTypes.Find(d => - d.TaskId == instance.Process.CurrentTask.ElementId && d.AppLogic != null - ); - } - - if (dataType != null) - { - return (dataType.Id, _appModel.GetModelType(dataType.AppLogic.ClassRef)); - } - - return (null, null); - } - - private static Guid? GetDataId(Instance instance, string dataType) - { - string? dataId = instance.Data.Find(d => d.DataType == dataType)?.Id; - if (dataId != null) - { - return new Guid(dataId); - } - - return null; - } } diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 5914c07ea..bd3e0c027 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -18,32 +19,23 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; private readonly IAppModel _appModel; - private readonly IAppResources _appResources; - private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IOptions _appSettings; /// /// Initializes a new instance of the class. /// - /// - /// - /// - /// - /// - /// public ProcessTaskFinalizer( IAppMetadata appMetadata, IDataClient dataClient, IAppModel appModel, - IAppResources appResources, - LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IOptions appSettings ) { _appMetadata = appMetadata; _dataClient = dataClient; _appModel = appModel; - _appResources = appResources; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _appSettings = appSettings; } @@ -54,10 +46,15 @@ public async Task Finalize(string taskId, Instance instance) ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); List connectedDataTypes = applicationMetadata.DataTypes.FindAll(dt => dt.TaskId == taskId); - await RunRemoveFieldsInModelOnTaskComplete(instance, connectedDataTypes); + await RunRemoveFieldsInModelOnTaskComplete(instance, taskId, connectedDataTypes, language: null); } - private async Task RunRemoveFieldsInModelOnTaskComplete(Instance instance, List dataTypesToLock) + private async Task RunRemoveFieldsInModelOnTaskComplete( + Instance instance, + string taskId, + List dataTypesToLock, + string? language = null + ) { ArgumentNullException.ThrowIfNull(instance.Data); @@ -68,7 +65,14 @@ await Task.WhenAll( .Select( async (d) => { - await RemoveFieldsOnTaskComplete(instance, dataTypesToLock, d.dataElement, d.dataType); + await RemoveFieldsOnTaskComplete( + instance, + taskId, + dataTypesToLock, + d.dataElement, + d.dataType, + language + ); } ) ); @@ -76,9 +80,11 @@ await Task.WhenAll( private async Task RemoveFieldsOnTaskComplete( Instance instance, + string taskId, List dataTypesToLock, DataElement dataElement, - DataType dataType + DataType dataType, + string? language = null ) { // Download the data @@ -99,11 +105,11 @@ DataType dataType // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) { - LayoutSet? layoutSet = _appResources.GetLayoutSetForTask(dataType.TaskId); LayoutEvaluatorState evaluationState = await _layoutEvaluatorStateInitializer.Init( instance, - data, - layoutSet?.Id + taskId, + gatewayAction: null, + language ); LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.Ignore); } diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 8b8916e12..748c8f489 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Altinn.App.Core.Features; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -15,11 +14,10 @@ namespace Altinn.App.Core.Internal.Validation; public class ValidationService : IValidationService { private readonly IValidatorFactory _validatorFactory; - private readonly IDataClient _dataClient; - private readonly IAppModel _appModel; private readonly IAppMetadata _appMetadata; private readonly ILogger _logger; private readonly Telemetry? _telemetry; + private readonly ICachedFormDataAccessor _formDataCache; /// /// Constructor with DI services @@ -30,14 +28,14 @@ public ValidationService( IAppModel appModel, IAppMetadata appMetadata, ILogger logger, + ICachedFormDataAccessor formDataCache, Telemetry? telemetry = null ) { _validatorFactory = validatorFactory; - _dataClient = dataClient; - _appModel = appModel; _appMetadata = appMetadata; _logger = logger; + _formDataCache = formDataCache; _telemetry = telemetry; } @@ -70,7 +68,7 @@ public async Task> ValidateInstanceAtTask(Instance instanc ) ); - List[][] lists = await Task.WhenAll(taskIssuesTask, dataIssuesTask); + var lists = await Task.WhenAll(taskIssuesTask, dataIssuesTask); // Flatten the list of lists to a single list of issues return lists.SelectMany(x => x.SelectMany(y => y)).ToList(); } @@ -86,8 +84,8 @@ private Task[]> RunTaskValidators(Instance instance, strin try { _logger.LogDebug( - "Start running validator {validatorName} on task {taskId} in instance {instanceId}", - v.GetType().Name, + "Start running validator {ValidatorName} on task {TaskId} in instance {InstanceId}", + v.ValidationSource, taskId, instance.Id ); @@ -99,8 +97,8 @@ private Task[]> RunTaskValidators(Instance instance, strin { _logger.LogError( e, - "Error while running validator {validatorName} on task {taskId} in instance {instanceId}", - v.GetType().Name, + "Error while running validator {ValidatorName} on task {TaskId} in instance {InstanceId}", + v.ValidationSource, taskId, instance.Id ); @@ -136,19 +134,7 @@ public async Task> ValidateDataElement( // Run extra validation on form data elements with app logic if (dataType.AppLogic?.ClassRef is not null) { - 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, CultureInfo.InvariantCulture); - var data = await _dataClient.GetFormData( - instanceGuid, - modelType, - instance.Org, - app, - instanceOwnerPartyId, - Guid.Parse(dataElement.Id) - ); // TODO: Add method that accepts instance and dataElement + var data = await _formDataCache.Get(instance, dataElement); var formDataIssuesDictionary = await ValidateFormData( instance, dataElement, @@ -185,7 +171,7 @@ private Task[]> RunDataElementValidators( { _logger.LogDebug( "Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.GetType().Name, + v.ValidationSource, dataElement.DataType, dataElement.Id, instance.Id @@ -199,7 +185,7 @@ private Task[]> RunDataElementValidators( _logger.LogError( e, "Error while running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.GetType().Name, + v.ValidationSource, dataElement.DataType, dataElement.Id, instance.Id @@ -231,6 +217,9 @@ public async Task>> ValidateFormData( using var activity = _telemetry?.StartValidateFormDataActivity(instance, dataElement); + // Set data from request instead of fetching the old data. + _formDataCache.Set(dataElement, data); + // Locate the relevant data validator services from normal and keyed services var dataValidators = _validatorFactory .GetFormDataValidators(dataType.Id) @@ -238,39 +227,41 @@ public async Task>> ValidateFormData( .Where(dv => previousData is null || dv.HasRelevantChanges(data, previousData)) .ToArray(); - var issuesLists = await Task.WhenAll( - dataValidators.Select(async v => + var validationTasks = dataValidators.Select(async v => + { + using var activity = _telemetry?.StartRunFormDataValidatorActivity(v); + try { - using var activity = _telemetry?.StartRunFormDataValidatorActivity(v); - try - { - _logger.LogDebug( - "Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.GetType().Name, - dataElement.DataType, - dataElement.Id, - instance.Id - ); - var issues = await v.ValidateFormData(instance, dataElement, data, language); - issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the Source is set to the ValidatorSource - return issues; - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.GetType().Name, - dataElement.DataType, - dataElement.Id, - instance.Id - ); - activity?.Errored(e); - throw; - } - }) - ); + _logger.LogDebug( + "Start running validator {ValidatorName} on {DataType} for data element {DataElementId} in instance {InstanceId}", + v.ValidationSource, + dataElement.DataType, + dataElement.Id, + instance.Id + ); + var issues = await v.ValidateFormData(instance, dataElement, data, language); + issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the Source is set to the ValidatorSource + return issues; + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while running validator {ValidatorName} on {DataType} for data element {DataElementId} in instance {InstanceId}", + v.ValidationSource, + dataElement.DataType, + dataElement.Id, + instance.Id + ); + activity?.Errored(e); + throw; + } + }); + + var validationSources = dataValidators.Select(d => d.ValidationSource).ToList(); + + var issuesLists = await Task.WhenAll(validationTasks); - return dataValidators.Zip(issuesLists).ToDictionary(kv => kv.First.ValidationSource, kv => kv.Second); + return validationSources.Zip(issuesLists).ToDictionary(kv => kv.First, kv => kv.Second); } } diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 2cabed33f..8c289194e 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -67,7 +67,7 @@ public ComponentContext( /// /// Get all children and children of children of this componentContext (not including this) /// - public IEnumerable Decendants + public IEnumerable Descendants { get { diff --git a/src/Altinn.App.Core/Models/Expressions/Expression.cs b/src/Altinn.App.Core/Models/Expressions/Expression.cs index d53dd5392..97c641d8b 100644 --- a/src/Altinn.App.Core/Models/Expressions/Expression.cs +++ b/src/Altinn.App.Core/Models/Expressions/Expression.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; @@ -10,17 +12,41 @@ namespace Altinn.App.Core.Models.Expressions; /// All props are marked as nullable, but a valid instance has either and or /// [JsonConverter(typeof(ExpressionConverter))] -public sealed class Expression +public readonly record struct Expression { + /// + /// Construct a value expression with the given value + /// + /// + public Expression(object? value) + { + Value = value; + } + + /// + /// Construct a function expression with the given function and arguments + /// + public Expression(ExpressionFunction function, List? args) + { + Function = function; + Args = args; + } + + /// + /// Test function to see if this is representing a function with args. + /// + [MemberNotNullWhen(true, nameof(Function), nameof(Args))] + public bool IsFunctionExpression => Function != ExpressionFunction.INVALID && Args != null; + /// /// Name of the function. Must be one those actually implemented in /// - public ExpressionFunction? Function { get; set; } + public ExpressionFunction Function { get; } /// /// List of arguments to the function. These expressions will be evaluated before passed to the function. /// - public List? Args { get; set; } + public List? Args { get; } /// /// Some expressions are just literal values that evaluate to the same value. @@ -28,5 +54,18 @@ public sealed class Expression /// /// If isn't null, and must be /// - public object? Value { get; set; } + public object? Value { get; } + + /// + /// Static helper to create an expression with the value of false + /// + public static Expression False => new(false); + + /// + /// Overridden for better debugging experience + /// + public override string ToString() + { + return JsonSerializer.Serialize(this); + } } diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs index 1761a82bd..a3f319daf 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs @@ -12,23 +12,23 @@ namespace Altinn.App.Core.Models.Expressions; public class ExpressionConverter : JsonConverter { /// - public override Expression? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override Expression Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return ReadNotNull(ref reader, options); + return ReadStatic(ref reader, options); } /// /// Same as , but without the nullable return type required by the interface. Throw an exeption instead. /// - public static Expression ReadNotNull(ref Utf8JsonReader reader, JsonSerializerOptions options) + public static Expression ReadStatic(ref Utf8JsonReader reader, JsonSerializerOptions options) { return reader.TokenType switch { - JsonTokenType.True => new Expression { Value = true }, - JsonTokenType.False => new Expression { Value = false }, - JsonTokenType.String => new Expression { Value = reader.GetString() }, - JsonTokenType.Number => new Expression { Value = reader.GetDouble() }, - JsonTokenType.Null => new Expression { Value = null }, + JsonTokenType.True => new Expression(true), + JsonTokenType.False => new Expression(false), + JsonTokenType.String => new Expression(reader.GetString()), + JsonTokenType.Number => new Expression(reader.GetDouble()), + JsonTokenType.Null => new Expression(null), JsonTokenType.StartArray => ReadArray(ref reader, options), JsonTokenType.StartObject => throw new JsonException("Invalid type \"object\""), _ => throw new JsonException(), @@ -42,29 +42,32 @@ private static Expression ReadArray(ref Utf8JsonReader reader, JsonSerializerOpt { throw new JsonException("Missing function name in expression"); } + if (reader.TokenType != JsonTokenType.String) { throw new JsonException("Function name in expression should be string"); } + var stringFunction = reader.GetString(); if (!Enum.TryParse(stringFunction, ignoreCase: false, out var functionEnum)) { throw new JsonException($"Function \"{stringFunction}\" not implemented"); } - var expr = new Expression() { Function = functionEnum, Args = new List() }; + + var args = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { - expr.Args.Add(ReadNotNull(ref reader, options)); + args.Add(ReadStatic(ref reader, options)); } - return expr; + return new Expression(functionEnum, args); } /// public override void Write(Utf8JsonWriter writer, Expression value, JsonSerializerOptions options) { - if (value.Function != null && value.Args != null) + if (value.IsFunctionExpression) { // Serialize with as an array expression ["functionName", arg1, arg2, ...] writer.WriteStartArray(); diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionFunctionEnum.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionFunctionEnum.cs index 93cdf232b..c1abd0f1d 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionFunctionEnum.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionFunctionEnum.cs @@ -1,7 +1,10 @@ +// ReSharper disable InconsistentNaming namespace Altinn.App.Core.Models.Expressions; /// /// Enumeration for valid functions in Layout Expressions +/// +/// Note that capitalization follows the JavaScript convention of camelCase for function names /// public enum ExpressionFunction { @@ -139,4 +142,9 @@ public enum ExpressionFunction /// Get the action performed in task prior to bpmn gateway /// gatewayAction, + + /// + /// Gets the currently selected language (or "nb" if not in a context where language is available) + /// + language, } diff --git a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs index c4982edc8..595e03e9a 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs @@ -11,7 +11,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// Includes that will be initialized to an empty dictionary /// for components that don't have them. /// -public class BaseComponent +public record BaseComponent { /// /// Constructor for @@ -19,16 +19,16 @@ public class BaseComponent public BaseComponent( string id, string type, - IReadOnlyDictionary? dataModelBindings, - Expression? hidden, - Expression? required, - Expression? readOnly, + IReadOnlyDictionary? dataModelBindings, + Expression hidden, + Expression required, + Expression readOnly, IReadOnlyDictionary? additionalProperties ) { Id = id; Type = type; - DataModelBindings = dataModelBindings ?? ImmutableDictionary.Empty; + DataModelBindings = dataModelBindings ?? ImmutableDictionary.Empty; Hidden = hidden; Required = required; ReadOnly = readOnly; @@ -60,22 +60,22 @@ public string PageId /// /// Layout Expression that can be evaluated to see if component should be hidden /// - public Expression? Hidden { get; } + public Expression Hidden { get; } /// /// Layout Expression that can be evaluated to see if component should be required /// - public Expression? Required { get; } + public Expression Required { get; } /// /// Layout Expression that can be evaluated to see if component should be read only /// - public Expression? ReadOnly { get; } + public Expression ReadOnly { get; } /// /// Data model bindings for the component or group /// - public IReadOnlyDictionary DataModelBindings { get; } + public IReadOnlyDictionary DataModelBindings { get; } /// /// The group or page that this component is part of. NULL for page components diff --git a/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs index 38c921ca0..e26bf4e4a 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs @@ -7,7 +7,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Component specialisation for repeating groups with maxCount > 1 /// -public class GridComponent : GroupComponent +public record GridComponent : GroupComponent { /// /// Constructor for RepeatingGroupComponent @@ -15,12 +15,12 @@ public class GridComponent : GroupComponent public GridComponent( string id, string type, - IReadOnlyDictionary? dataModelBindings, - IEnumerable children, - IEnumerable? childIDs, - Expression? hidden, - Expression? required, - Expression? readOnly, + IReadOnlyDictionary? dataModelBindings, + IReadOnlyCollection children, + IReadOnlyCollection? childIDs, + Expression hidden, + Expression required, + Expression readOnly, IReadOnlyDictionary? additionalProperties ) : base(id, type, dataModelBindings, children, childIDs, hidden, required, readOnly, additionalProperties) { } @@ -29,7 +29,7 @@ public GridComponent( /// /// Class for parsing a Grid component's rows and cells and extracting the child component IDs /// -public class GridConfig +public record GridConfig { /// /// Reads the Grid component's rows and returns the child component IDs @@ -53,7 +53,7 @@ public static List ReadGridChildren(ref Utf8JsonReader reader, JsonSeria /// /// Defines a row in a grid /// - public class GridRow + public record GridRow { /// /// Cells in the row @@ -64,7 +64,7 @@ public class GridRow /// /// Defines a cell in a grid /// - public class GridCell + public record GridCell { /// /// The component ID of the cell diff --git a/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs index 6a9724e31..2983e8ac7 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs @@ -5,7 +5,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Tag component to signify that this is a group component /// -public class GroupComponent : BaseComponent +public record GroupComponent : BaseComponent { /// /// Constructor for GroupComponent @@ -13,12 +13,12 @@ public class GroupComponent : BaseComponent public GroupComponent( string id, string type, - IReadOnlyDictionary? dataModelBindings, - IEnumerable children, - IEnumerable? childIDs, - Expression? hidden, - Expression? required, - Expression? readOnly, + IReadOnlyDictionary? dataModelBindings, + IReadOnlyCollection children, + IReadOnlyCollection? childIDs, + Expression hidden, + Expression required, + Expression readOnly, IReadOnlyDictionary? additionalProperties ) : base(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties) diff --git a/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs index 793a45f91..eedaa80a7 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs @@ -5,7 +5,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Custom component for handeling the special fields that represents an option. /// -public class OptionsComponent : BaseComponent +public record OptionsComponent : BaseComponent { /// /// The ID that references and @@ -33,10 +33,10 @@ public class OptionsComponent : BaseComponent public OptionsComponent( string id, string type, - IReadOnlyDictionary? dataModelBindings, - Expression? hidden, - Expression? required, - Expression? readOnly, + IReadOnlyDictionary? dataModelBindings, + Expression hidden, + Expression required, + Expression readOnly, string? optionId, List? options, OptionsSource? optionsSource, @@ -55,7 +55,7 @@ public OptionsComponent( /// /// This is an optional child element of that specifies that /// -public class OptionsSource +public record OptionsSource { /// /// Constructor for diff --git a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs index 9d0f0b664..505f040f4 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs @@ -7,7 +7,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// Component like object to add Page as a group like object /// [JsonConverter(typeof(PageComponentConverter))] -public class PageComponent : GroupComponent +public record PageComponent : GroupComponent { /// /// Constructor for PageComponent @@ -16,9 +16,9 @@ public PageComponent( string id, List children, Dictionary componentLookup, - Expression? hidden, - Expression? required, - Expression? readOnly, + Expression hidden, + Expression required, + Expression readOnly, IReadOnlyDictionary? extra ) : base(id, "page", null, children, null, hidden, required, readOnly, extra) diff --git a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs index b71e780f5..52bfb9620 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs @@ -5,7 +5,7 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Component specialisation for repeating groups with maxCount > 1 /// -public class RepeatingGroupComponent : GroupComponent +public record RepeatingGroupComponent : GroupComponent { /// /// Constructor for RepeatingGroupComponent @@ -13,14 +13,14 @@ public class RepeatingGroupComponent : GroupComponent public RepeatingGroupComponent( string id, string type, - IReadOnlyDictionary? dataModelBindings, - IEnumerable children, - IEnumerable? childIDs, + IReadOnlyDictionary? dataModelBindings, + IReadOnlyCollection children, + IReadOnlyCollection? childIDs, int maxCount, - Expression? hidden, - Expression? hiddenRow, - Expression? required, - Expression? readOnly, + Expression hidden, + Expression hiddenRow, + Expression required, + Expression readOnly, IReadOnlyDictionary? additionalProperties ) : base(id, type, dataModelBindings, children, childIDs, hidden, required, readOnly, additionalProperties) @@ -37,5 +37,5 @@ public RepeatingGroupComponent( /// /// Layout Expression that can be evaluated to see if row should be hidden /// - public Expression? HiddenRow { get; } + public Expression HiddenRow { get; } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs index 934ec41b7..53ba0a7f6 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs @@ -5,12 +5,12 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Custom component for handeling the special fields in "type" = "Summary" /// -public class SummaryComponent : BaseComponent +public record SummaryComponent : BaseComponent { /// /// of the component this summary references /// - public string ComponentRef { get; set; } + public string ComponentRef { get; } /// /// Constructor @@ -18,11 +18,11 @@ public class SummaryComponent : BaseComponent public SummaryComponent( string id, string type, - Expression? hidden, + Expression hidden, string componentRef, IReadOnlyDictionary? additionalProperties ) - : base(id, type, null, hidden, null, null, additionalProperties) + : base(id, type, null, hidden, Expression.False, Expression.False, additionalProperties) { ComponentRef = componentRef; } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 57acefe95..44f3c8fb0 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -1,35 +1,33 @@ +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout.Components; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Models.Layout; /// -/// Class for handeling a full layout/layoutset +/// Class for handling a full layout/layoutset /// -public class LayoutModel +public record LayoutModel { /// /// Dictionary to hold the different pages that are part of this LayoutModel /// - public Dictionary Pages { get; init; } = new Dictionary(); + public required IReadOnlyDictionary Pages { get; init; } /// - /// Get a page from the dictionary + /// The default data type for the layout model /// - public PageComponent GetPage(string pageName) - { - if (Pages.TryGetValue(pageName, out var page)) - { - return page; - } - throw new ArgumentException($"Unknown page name {pageName}"); - } + public required DataType DefaultDataType { get; init; } /// - /// Get a specific component on a specifc page. + /// Get a specific component on a specific page. /// public BaseComponent GetComponent(string pageName, string componentId) { - var page = GetPage(pageName); + if (!Pages.TryGetValue(pageName, out var page)) + { + throw new ArgumentException($"Unknown page name {pageName}"); + } if (!page.ComponentLookup.TryGetValue(componentId, out var component)) { @@ -54,4 +52,44 @@ public IEnumerable GetComponents() nodes.Push(n); } } + + /// + /// Get all external model references used in the layout model + /// + public IEnumerable GetReferencedDataTypeIds() + { + var externalModelReferences = new HashSet(); + foreach (var component in GetComponents()) + { + // Add data model references from DataModelBindings + externalModelReferences.UnionWith( + component.DataModelBindings.Values.Select(d => d.DataType).OfType() + ); + + // Add data model references from expressions + AddExternalModelReferences(component.Hidden, externalModelReferences); + AddExternalModelReferences(component.ReadOnly, externalModelReferences); + AddExternalModelReferences(component.Required, externalModelReferences); + //TODO: add more expressions when backend uses them + } + + //Ensure that the defaultData type is first in the resulting enumerable. + externalModelReferences.Remove(DefaultDataType.Id); + return externalModelReferences.Prepend(DefaultDataType.Id); + } + + private static void AddExternalModelReferences(Expression expression, HashSet externalModelReferences) + { + if ( + expression is + { Function: ExpressionFunction.dataModel, Args: [_, { Value: string externalModelReference }] } + ) + { + externalModelReferences.Add(externalModelReference); + } + else + { + expression.Args?.ForEach(arg => AddExternalModelReferences(arg, externalModelReferences)); + } + } } diff --git a/src/Altinn.App.Core/Models/Layout/ModelBinding.cs b/src/Altinn.App.Core/Models/Layout/ModelBinding.cs new file mode 100644 index 000000000..14ad613cd --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/ModelBinding.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models.Layout; + +/// +/// Wrapper type for a model binding with optional data type specification +/// +public readonly record struct ModelBinding +{ + /// + /// The field in the model the binding is for + /// + [JsonPropertyName("field")] + public required string Field { get; init; } + + /// + /// The data type the binding refers to (default model for layout if null) + /// + [JsonPropertyName("dataType")] + public string? DataType { get; init; } + + /// + /// Implicit conversion from string to for + /// backwards convenience + /// + public static implicit operator ModelBinding(string field) + { + return new ModelBinding { Field = field, }; + } +} diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index 0ac0596b1..e07f096b0 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -36,7 +36,7 @@ public static void SetAsyncLocalPageName(string pageName) } /// - public override PageComponent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override PageComponent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Try to get pagename from metadata in this.AddPageName var pageName = _pageName.Value ?? "UnknownPageName"; @@ -131,13 +131,13 @@ private PageComponent ReadData(ref Utf8JsonReader reader, string pageName, JsonS (componentListFlat, componentLookup, childToGroupMapping) = ReadLayout(ref reader, options); break; case "hidden": - hidden = ExpressionConverter.ReadNotNull(ref reader, options); + hidden = ExpressionConverter.ReadStatic(ref reader, options); break; case "required": - required = ExpressionConverter.ReadNotNull(ref reader, options); + required = ExpressionConverter.ReadStatic(ref reader, options); break; case "readonly": - readOnly = ExpressionConverter.ReadNotNull(ref reader, options); + readOnly = ExpressionConverter.ReadStatic(ref reader, options); break; default: // read extra properties @@ -153,7 +153,15 @@ private PageComponent ReadData(ref Utf8JsonReader reader, string pageName, JsonS var layout = ProcessLayout(componentListFlat, componentLookup, childToGroupMapping); - return new PageComponent(pageName, layout, componentLookup, hidden, required, readOnly, additionalProperties); + return new PageComponent( + pageName, + layout, + componentLookup, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, + additionalProperties + ); } private (List, Dictionary, Dictionary) ReadLayout( @@ -258,7 +266,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt } string? id = null; string? type = null; - Dictionary? dataModelBindings = null; + Dictionary? dataModelBindings = null; Expression? hidden = null; Expression? hiddenRow = null; Expression? required = null; @@ -303,8 +311,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt type = reader.GetString(); break; case "datamodelbindings": - // TODO: deserialize directly to make LineNumber and BytePositionInLine to give better errors - dataModelBindings = JsonSerializer.Deserialize>(ref reader, options); + dataModelBindings = DeserializeModelBindings(ref reader, options); break; // case "textresourcebindings": // break; @@ -321,16 +328,16 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt maxCount = reader.GetInt32(); break; case "hidden": - hidden = ExpressionConverter.ReadNotNull(ref reader, options); + hidden = ExpressionConverter.ReadStatic(ref reader, options); break; case "hiddenrow": - hiddenRow = ExpressionConverter.ReadNotNull(ref reader, options); + hiddenRow = ExpressionConverter.ReadStatic(ref reader, options); break; case "required": - required = ExpressionConverter.ReadNotNull(ref reader, options); + required = ExpressionConverter.ReadStatic(ref reader, options); break; case "readonly": - readOnly = ExpressionConverter.ReadNotNull(ref reader, options); + readOnly = ExpressionConverter.ReadStatic(ref reader, options); break; // summary case "componentref": @@ -379,10 +386,10 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt new List(), children, maxCount, - hidden, - hiddenRow, - required, - readOnly, + hidden ?? Expression.False, + hiddenRow ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, additionalProperties ); return directRepComponent; @@ -409,10 +416,10 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt new List(), children, maxCount, - hidden, - hiddenRow, - required, - readOnly, + hidden ?? Expression.False, + hiddenRow ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, additionalProperties ); return repComponent; @@ -425,9 +432,9 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt dataModelBindings, new List(), children, - hidden, - required, - readOnly, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, additionalProperties ); return groupComponent; @@ -439,15 +446,15 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt dataModelBindings, new List(), children, - hidden, - required, - readOnly, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, additionalProperties ); return gridComponent; case "summary": ValidateSummary(componentRef); - return new SummaryComponent(id, type, hidden, componentRef, additionalProperties); + return new SummaryComponent(id, type, hidden ?? Expression.False, componentRef, additionalProperties); case "checkboxes": case "radiobuttons": case "dropdown": @@ -456,9 +463,9 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt id, type, dataModelBindings, - hidden, - required, - readOnly, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, optionId, literalOptions, optionsSource, @@ -467,8 +474,48 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt ); } - // Most compoents are handled as BaseComponent - return new BaseComponent(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties); + // Most components are handled as BaseComponent + return new BaseComponent( + id, + type, + dataModelBindings, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, + additionalProperties + ); + } + + private static Dictionary DeserializeModelBindings( + ref Utf8JsonReader reader, + JsonSerializerOptions options + ) + { + var modelBindings = new Dictionary(); + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for \"dataModelBindings\""); + } + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + // ! Token type is PropertyName so value is a string + var propertyName = reader.GetString()!; + reader.Read(); + modelBindings[propertyName] = reader.TokenType switch + { + JsonTokenType.String => new ModelBinding { Field = reader.GetString() ?? throw new JsonException(), }, + JsonTokenType.StartObject => JsonSerializer.Deserialize(ref reader, options), + _ => throw new JsonException() + }; + } + + return modelBindings; } private static void ValidateOptions( diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index b01772c24..eddcdfc51 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Headers; +using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; @@ -37,6 +38,7 @@ public class DataControllerPatchTests : ApiTestBase, IClassFixture(patch, null, HttpStatusCode.OK); + var newModelElement = parsedResponse.NewDataModel.Should().BeOfType().Which; + var newModel = newModelElement.Deserialize()!; + newModel.Melding!.Name.Should().BeNull(); + var requiredList = parsedResponse.ValidationIssues.Should().ContainKey("Required").WhoseValue; var requiredName = requiredList.Should().ContainSingle().Which; requiredName.Field.Should().Be("melding.name"); requiredName.Description.Should().Be("melding.name is required in component with id name"); - var newModelElement = parsedResponse.NewDataModel.Should().BeOfType().Which; - var newModel = newModelElement.Deserialize()!; - newModel.Melding!.Name.Should().BeNull(); + // Run full validation to see that result is the same + using var client = GetRootedClient(Org, App, UserId, null); + var validationResponse = await client.GetAsync($"/{Org}/{App}/instances/{InstanceId}/validate"); + validationResponse.Should().HaveStatusCode(HttpStatusCode.OK); + var validationResponseString = await validationResponse.Content.ReadAsStringAsync(); + var validationResponseObject = JsonSerializer.Deserialize>( + validationResponseString, + _jsonSerializerOptions + )!; + validationResponseObject.Should().BeEquivalentTo(parsedResponse.ValidationIssues.Values.SelectMany(d => d)); _dataProcessorMock.Verify( p => diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 411d5ce30..bb1bf0642 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -3905,7 +3905,7 @@ } } }, - "/{org}/{app}/api/validationconfig/{id}": { + "/{org}/{app}/api/validationconfig/{dataTypeId}": { "get": { "tags": [ "Resource" @@ -3928,7 +3928,7 @@ } }, { - "name": "id", + "name": "dataTypeId", "in": "path", "required": true, "schema": { diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 15dd94350..8d1c48685 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -2380,7 +2380,7 @@ paths: responses: '200': description: OK - '/{org}/{app}/api/validationconfig/{id}': + '/{org}/{app}/api/validationconfig/{dataTypeId}': get: tags: - Resource @@ -2395,7 +2395,7 @@ paths: required: true schema: type: string - - name: id + - name: dataTypeId in: path required: true schema: diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index c77f805c1..fe05f569e 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -1,53 +1,56 @@ +using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Validation; using Altinn.App.Core.Tests.Helpers; using Altinn.App.Core.Tests.LayoutExpressions; +using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; +using Xunit.Abstractions; +using Xunit.Sdk; namespace Altinn.App.Core.Tests.Features.Validators.Default; public class ExpressionValidatorTests { + private readonly ITestOutputHelper _output; private readonly ExpressionValidator _validator; private readonly Mock> _logger = new(); private readonly Mock _appResources = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); + private readonly Mock _dataClient = new(MockBehavior.Strict); private readonly IOptions _frontendSettings = Microsoft.Extensions.Options.Options.Create( new FrontEndSettings() ); - private readonly Mock _layoutInitializer; + private readonly Mock _layoutInitializer = new(MockBehavior.Strict); + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true }; - public ExpressionValidatorTests() + public ExpressionValidatorTests(ITestOutputHelper output) { + _output = output; _appMetadata .Setup(ar => ar.GetApplicationMetadata()) - .ReturnsAsync(new ApplicationMetadata("org/app") { DataTypes = new List() { new() { } } }); - _appResources.Setup(ar => ar.GetLayoutSetForTask(It.IsAny())).Returns(new LayoutSet()); - _layoutInitializer = new(MockBehavior.Strict, _appResources.Object, _frontendSettings) { CallBase = false }; - _validator = new ExpressionValidator( - _logger.Object, - _appResources.Object, - _layoutInitializer.Object, - _appMetadata.Object - ); + .ReturnsAsync( + new ApplicationMetadata("org/app") { DataTypes = new List { new() { Id = "default" } } } + ); + _appResources.Setup(ar => ar.GetLayoutSetForTask("Task_1")).Returns(new LayoutSet()); + _validator = new ExpressionValidator(_logger.Object, _appResources.Object, _layoutInitializer.Object); } - private static readonly JsonSerializerOptions _jsonSerializerOptions = - new() { ReadCommentHandling = JsonCommentHandling.Skip, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public ExpressionValidationTestModel LoadData(string fileName, string folder) { var data = File.ReadAllText(Path.Join(folder, fileName)); @@ -71,25 +74,22 @@ public async Task RunExpressionValidationTestsForShared(string fileName, string private async Task RunExpressionValidationTest(string fileName, string folder) { var testCase = LoadData(fileName, folder); - var instance = new Instance(); - var dataElement = new DataElement(); - var dataModel = new JsonDataModel(testCase.FormData); + var instance = new Instance() { Process = new() { CurrentTask = new() { ElementId = "Task_1", } } }; + var dataElement = new DataElement { DataType = "default", }; + + var dataModel = DynamicClassBuilder.DataModelFromJsonDocument(testCase.FormData, dataElement); var evaluatorState = new LayoutEvaluatorState(dataModel, testCase.Layouts, _frontendSettings.Value, instance); _layoutInitializer .Setup(init => - init.Init( - It.Is(i => i == instance), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) + init.Init(It.Is(i => i == instance), "Task_1", It.IsAny(), It.IsAny()) ) .ReturnsAsync(evaluatorState); _appResources - .Setup(ar => ar.GetValidationConfiguration(It.IsAny())) + .Setup(ar => ar.GetValidationConfiguration("default")) .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); + _appResources.Setup(ar => ar.GetLayoutSetForTask(null!)).Returns(new LayoutSet() { DataType = "default", }); var validationIssues = await _validator.ValidateFormData(instance, dataElement, null!, null); @@ -111,28 +111,37 @@ private async Task RunExpressionValidationTest(string fileName, string folder) } } -public class ExpressionValidationTestModel +public record ExpressionValidationTestModel { + [JsonPropertyName("name")] public required string Name { get; set; } + [JsonPropertyName("expects")] public required ExpectedObject[] Expects { get; set; } + [JsonPropertyName("validationConfig")] public required JsonElement ValidationConfig { get; set; } - public required JsonObject FormData { get; set; } + [JsonPropertyName("formData")] + public required JsonElement FormData { get; set; } + [JsonPropertyName("layouts")] [JsonConverter(typeof(LayoutModelConverterFromObject))] public required LayoutModel Layouts { get; set; } public class ExpectedObject { + [JsonPropertyName("message")] public required string Message { get; set; } + [JsonPropertyName("severity")] [JsonConverter(typeof(FrontendSeverityConverter))] public required ValidationIssueSeverity Severity { get; set; } + [JsonPropertyName("field")] public required string Field { get; set; } + [JsonPropertyName("componentId")] public required string ComponentId { get; set; } } } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs index dcf31c318..378b21155 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs @@ -11,6 +11,7 @@ using Altinn.Platform.Storage.Interface.Enums; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; @@ -23,6 +24,7 @@ public class ValidationServiceOldTests private readonly Mock _dataClientMock = new(); private readonly Mock _appModelMock = new(); private readonly Mock _appMetadataMock = new(); + private readonly Mock _httpContextAccessorMock = new(); private readonly ServiceCollection _serviceCollection = new(); private readonly ApplicationMetadata _applicationMetadata = @@ -50,6 +52,11 @@ public ValidationServiceOldTests() _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(); + _serviceCollection.AddScoped(); + + _httpContextAccessorMock.SetupGet(h => h.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); + _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); + _appMetadataMock.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index a9a67f3d3..988a4edff 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -53,7 +54,8 @@ private class MyModel InstanceOwner = new InstanceOwner() { PartyId = DefaultPartyId.ToString(), }, Org = DefaultOrg, AppId = DefaultAppId, - Data = new List() { DefaultDataElement, } + Data = [DefaultDataElement], + Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task1" } } }; private static readonly ApplicationMetadata DefaultAppMetadata = @@ -89,6 +91,8 @@ private class MyModel private readonly Mock _formDataValidatorAlwaysMock = new(MockBehavior.Strict) { Name = "alwaysFormDataValidator" }; + private readonly Mock _httpContextAccessorMock = new(); + private readonly ServiceCollection _serviceCollection = new(); public ValidationServiceTests() @@ -101,6 +105,10 @@ public ValidationServiceTests() _serviceCollection.AddSingleton(_appMetadataMock.Object); _appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(DefaultAppMetadata); _serviceCollection.AddSingleton(); + _serviceCollection.AddScoped(); + + _httpContextAccessorMock.Setup(h => h.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); + _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); // NeverUsedValidators _serviceCollection.AddSingleton(_taskValidatorNeverMock.Object); diff --git a/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs b/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs deleted file mode 100644 index e5f63e48f..000000000 --- a/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs +++ /dev/null @@ -1,349 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Altinn.App.Core.Helpers; -using Altinn.App.Core.Helpers.DataModel; - -namespace Altinn.App.Core.Tests.Helpers; - -/// -/// Implementation of for data models based on JsonObject (mainliy for testing ) -/// -/// -/// This class is written to enable the use of shared tests (with frontend) where the datamodel is defined -/// in json. It's hard to IL generate proper C# classes to use the normal in tests -/// -public class JsonDataModel : IDataModelAccessor -{ - private readonly JsonObject? _modelRoot; - - /// - /// Constructor that creates a JsonDataModel based on a JsonObject - /// - public JsonDataModel(JsonObject? modelRoot) - { - _modelRoot = modelRoot; - } - - /// - public object? GetModelData(string key, ReadOnlySpan indicies = default) - { - if (_modelRoot is null) - { - return null; - } - - return GetModelDataRecursive(key.Split('.'), 0, _modelRoot, indicies); - } - - private object? GetModelDataRecursive(string[] keys, int index, JsonNode? currentModel, ReadOnlySpan indicies) - { - if (currentModel is null) - { - return null; - } - - if (index == keys.Length) - { - return currentModel switch - { - JsonValue jsonValue - => jsonValue.GetValue().ValueKind switch - { - JsonValueKind.String => jsonValue.GetValue(), - JsonValueKind.Number => jsonValue.GetValue(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ - => throw new NotImplementedException( - $"Get Data is not implemented for {jsonValue.GetType()}" - ), - }, - JsonObject obj => obj, - JsonArray arr => arr, - _ => throw new NotImplementedException($"Get Data is not implemented for {currentModel.GetType()}"), - }; - } - - var (key, groupIndex) = DataModel.ParseKeyPart(keys[index]); - - if ( - currentModel is not JsonObject - || !currentModel.AsObject().TryGetPropertyValue(key, out JsonNode? childModel) - ) - { - return null; - } - - if (childModel is JsonArray childArray) - { - if (groupIndex is null) - { - if (indicies.Length == 0) - { - return null; // Don't know index - } - - groupIndex = indicies[0]; - } - else - { - indicies = default; // when you use a literal index, the context indecies are not to be used later. - } - - var arrayElement = childArray.ElementAt((int)groupIndex); - return GetModelDataRecursive( - keys, - index + 1, - arrayElement, - indicies.Length > 0 ? indicies.Slice(1) : indicies - ); - } - - return GetModelDataRecursive(keys, index + 1, childModel, indicies); - } - - /// - public int? GetModelDataCount(string key, ReadOnlySpan indicies = default) - { - if (_modelRoot is null) - { - return null; - } - - return GetModelDataCountRecurs(key.Split('.'), 0, _modelRoot, indicies); - } - - private int? GetModelDataCountRecurs(string[] keys, int index, JsonNode? currentModel, ReadOnlySpan indicies) - { - if (index == keys.Length || currentModel is null) - { - return null; // Last key part was not an JsonValueKind.Array - } - - var (key, groupIndex) = DataModel.ParseKeyPart(keys[index]); - - if ( - currentModel is not JsonObject - || !currentModel.AsObject().TryGetPropertyValue(key, out JsonNode? childModel) - ) - { - return null; - } - - if (childModel is JsonArray childArray) - { - if (index == keys.Length - 1) - { - return childArray.Count; - } - - if (groupIndex is null) - { - if (indicies.Length == 0) - { - return null; // Error index for collection not specified - } - - groupIndex = indicies[0]; - } - else - { - indicies = default; // when you use a literal index, the context indecies are not to be used later. - } - - var arrayElement = childArray.ElementAt((int)groupIndex); - return GetModelDataCountRecurs( - keys, - index + 1, - arrayElement, - indicies.Length > 0 ? indicies.Slice(1) : indicies - ); - } - - return GetModelDataCountRecurs(keys, index + 1, childModel, indicies); - } - - /// - public string[] GetResolvedKeys(string key) - { - if (_modelRoot is null) - { - return []; - } - - var keyParts = key.Split('.'); - return GetResolvedKeysRecursive(keyParts, _modelRoot); - } - - private string[] GetResolvedKeysRecursive( - string[] keyParts, - JsonNode? currentModel, - int currentIndex = 0, - string currentKey = "" - ) - { - if (currentModel is null) - { - return []; - } - - if (currentIndex == keyParts.Length) - { - return [currentKey]; - } - - var (key, groupIndex) = DataModel.ParseKeyPart(keyParts[currentIndex]); - if ( - currentModel is not JsonObject - || !currentModel.AsObject().TryGetPropertyValue(key, out JsonNode? childModel) - ) - { - return []; - } - - if (childModel is JsonArray childArray) - { - // childModel is an array - if (groupIndex is null) - { - // Index not specified, recurse on all elements - int i = 0; - var resolvedKeys = new List(); - foreach (var child in childArray) - { - var newResolvedKeys = GetResolvedKeysRecursive( - keyParts, - child, - currentIndex + 1, - DataModel.JoinFieldKeyParts(currentKey, key + "[" + i + "]") - ); - resolvedKeys.AddRange(newResolvedKeys); - i++; - } - - return resolvedKeys.ToArray(); - } - else - { - // Index specified, recurse on that element - return GetResolvedKeysRecursive( - keyParts, - childModel, - currentIndex + 1, - DataModel.JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") - ); - } - } - - // Otherwise, just recurse - return GetResolvedKeysRecursive( - keyParts, - childModel, - currentIndex + 1, - DataModel.JoinFieldKeyParts(currentKey, key) - ); - } - - /// - public string AddIndicies(string key, ReadOnlySpan indicies = default) - { - if (indicies.Length == 0) - { - return key; - } - - var keys = key.Split('.'); - var outputKey = string.Empty; - JsonNode? currentModel = _modelRoot; - - foreach (var keyPart in keys) - { - var (currentKey, groupIndex) = DataModel.ParseKeyPart(keyPart); - var currentIndex = groupIndex ?? (indicies.Length > 0 ? indicies[0] : null); - - if (currentModel is not JsonObject currentObject) - { - throw new DataModelException("Cannot access property of a JsonValue or JsonArray"); - } - - if (!currentObject.TryGetPropertyValue(currentKey, out JsonNode? childModel)) - { - throw new DataModelException($"Cannot find property {currentKey} in {currentObject}"); - } - - if (childModel is JsonArray childArray && currentIndex is not null) - { - outputKey = DataModel.JoinFieldKeyParts(outputKey, currentKey + "[" + currentIndex + "]"); - currentModel = childArray.ElementAt((int)currentIndex); - if (indicies.Length > 0) - { - indicies = indicies.Slice(1); - } - } - else - { - if (groupIndex is not null) - { - throw new DataModelException("Index on non indexable property"); - } - - outputKey = DataModel.JoinFieldKeyParts(outputKey, currentKey); - currentModel = childModel; - } - } - - return outputKey; - } - - /// - public void RemoveField(string key, RowRemovalOption rowRemovalOption) - { - var keys_split = key.Split('.'); - var keys = keys_split[0..^1]; - var (lastKey, lastGroupIndex) = DataModel.ParseKeyPart(keys_split[^1]); - - object? modelData = GetModelDataRecursive(keys, 0, _modelRoot, default); - if (modelData is not JsonObject containingObject) - { - return; - } - - if (lastGroupIndex is not null) - { - // Remove row from list - if ( - !( - containingObject.TryGetPropertyValue(lastKey, out JsonNode? childModel) - && childModel is JsonArray childArray - ) - ) - { - throw new ArgumentException($"Tried to remove row {key}, ended in a non-list"); - } - - switch (rowRemovalOption) - { - case RowRemovalOption.DeleteRow: - childArray.RemoveAt((int)lastGroupIndex); - break; - case RowRemovalOption.SetToNull: - childArray[(int)lastGroupIndex] = null; - break; - case RowRemovalOption.Ignore: - return; - } - } - else - { - // Set the property to null - containingObject[lastKey] = null; - } - } - - /// - public bool VerifyKey(string key) - { - throw new NotImplementedException("Impossible to verify keys in a json model"); - } -} diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 49eba51d9..9b6a4c270 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -12,6 +12,7 @@ using FluentAssertions; using Json.Patch; using Json.Pointer; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using DataType = Altinn.Platform.Storage.Interface.Models.DataType; @@ -23,7 +24,12 @@ public class PatchServiceTests : IDisposable // Test data private static readonly Guid DataGuid = new("12345678-1234-1234-1234-123456789123"); - private readonly Instance _instance = new() { Id = "1337/12345678-1234-1234-1234-12345678912a" }; + private readonly Instance _instance = + new() + { + Id = "1337/12345678-1234-1234-1234-12345678912a", + Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task_1" } } + }; // Service mocks private readonly Mock> _vLoggerMock = new(MockBehavior.Loose); @@ -31,6 +37,7 @@ public class PatchServiceTests : IDisposable private readonly Mock _dataProcessorMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly Mock _httpContextAccessorMock = new(MockBehavior.Strict); private readonly TelemetrySink _telemetrySink = new(); // ValidatorMocks @@ -67,18 +74,22 @@ public PatchServiceTests() ) .ReturnsAsync(_dataElement) .Verifiable(); - var validatorFactory = new ValidatorFactory( - Enumerable.Empty(), - new List() { _dataElementValidator.Object }, - new List() { _formDataValidator.Object } - ); + _httpContextAccessorMock.SetupGet(hca => hca.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); + var validatorFactory = new ValidatorFactory([], [_dataElementValidator.Object], [_formDataValidator.Object]); var validationService = new ValidationService( validatorFactory, _dataClientMock.Object, _appModelMock.Object, _appMetadataMock.Object, - _vLoggerMock.Object + _vLoggerMock.Object, + new CachedFormDataAccessor( + _dataClientMock.Object, + _appMetadataMock.Object, + _appModelMock.Object, + _httpContextAccessorMock.Object + ) ); + _patchService = new PatchService( _appMetadataMock.Object, _dataClientMock.Object, diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index caa25b4dc..793354ff4 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -8,11 +8,13 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Tests.Internal.Process.TestData; using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Moq; @@ -23,6 +25,23 @@ public class ExpressionsExclusiveGatewayTests private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, }; + private readonly Mock _resources = new(MockBehavior.Strict); + private readonly Mock _appModel = new(MockBehavior.Strict); + private readonly Mock _appMetadata = new(MockBehavior.Strict); + private readonly Mock _dataClient = new(MockBehavior.Strict); + private readonly Mock _httpContextAccessor = new(MockBehavior.Strict); + + private const string Org = "ttd"; + private const string App = "test"; + private const string AppId = $"{Org}/{App}"; + private const string TaskId = "Task_1"; + private static readonly string _classRef = typeof(DummyModel).FullName!; + + public ExpressionsExclusiveGatewayTests() + { + _appModel.Setup(am => am.GetModelType(_classRef)).Returns(typeof(DummyModel)); + } + [Fact] public async Task FilterAsync_NoExpressions_ReturnsAllFlows() { @@ -35,7 +54,10 @@ public async Task FilterAsync_NoExpressions_ReturnsAllFlows() AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } } }; - IProcessExclusiveGateway gateway = SetupExpressionsGateway(dataTypes: dataTypes); + + var data = new DummyModel(); + + var gateway = SetupExpressionsGateway(dataTypes: dataTypes, formData: data); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = null, }, @@ -75,7 +97,9 @@ public async Task FilterAsync_Expression_filters_based_on_action() AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } } }; - IProcessExclusiveGateway gateway = SetupExpressionsGateway(dataTypes: dataTypes); + + var data = new DummyModel(); + var gateway = SetupExpressionsGateway(dataTypes: dataTypes, formData: data); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"equals\", [\"gatewayAction\"], \"confirm\"]", }, @@ -132,11 +156,10 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou } } }; - IProcessExclusiveGateway gateway = SetupExpressionsGateway( + var gateway = SetupExpressionsGateway( dataTypes: dataTypes, formData: formData, - layoutSets: LayoutSetsToString(layoutSets), - dataType: formData.GetType() + layoutSets: LayoutSetsToString(layoutSets) ); var outgoingFlows = new List { @@ -178,9 +201,10 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew new() { Id = "test", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.NotFound", } + AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } } }; + object formData = new DummyModel() { Amount = 1000, Submitter = "test" }; LayoutSets layoutSets = new LayoutSets() { @@ -194,11 +218,10 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew } } }; - IProcessExclusiveGateway gateway = SetupExpressionsGateway( + var gateway = SetupExpressionsGateway( dataTypes: dataTypes, formData: formData, - layoutSets: LayoutSetsToString(layoutSets), - dataType: formData.GetType() + layoutSets: LayoutSetsToString(layoutSets) ); var outgoingFlows = new List { @@ -213,7 +236,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, Data = new() { - new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "aa" } + new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "test" } } }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", DataTypeId = "aa" }; @@ -226,27 +249,22 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew Assert.Equal("2", result[0].Id); } - private static ExpressionsExclusiveGateway SetupExpressionsGateway( + private ExpressionsExclusiveGateway SetupExpressionsGateway( List dataTypes, string? layoutSets = null, - object? formData = null, - Type? dataType = null + object? formData = null ) { - var resources = new Mock(); - var appModel = new Mock(); - var appMetadata = new Mock(); - var dataClient = new Mock(); - - resources.Setup(r => r.GetLayoutSets()).Returns(layoutSets ?? string.Empty); - appMetadata + _resources.Setup(r => r.GetLayoutSets()).Returns(layoutSets ?? string.Empty); + _appMetadata .Setup(m => m.GetApplicationMetadata()) .ReturnsAsync(new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }); - resources - .Setup(r => r.GetLayoutModel(It.IsAny())) + _resources + .Setup(r => r.GetLayoutModelForTask(It.IsAny())) .Returns( new LayoutModel() { + DefaultDataType = new() { Id = "test", }, Pages = new Dictionary() { { @@ -255,9 +273,9 @@ private static ExpressionsExclusiveGateway SetupExpressionsGateway( "Page1", new List(), new Dictionary(), - null, - null, - null, + Expression.False, + Expression.False, + Expression.False, null ) } @@ -266,7 +284,7 @@ private static ExpressionsExclusiveGateway SetupExpressionsGateway( ); if (formData != null) { - dataClient + _dataClient .Setup(d => d.GetFormData( It.IsAny(), @@ -280,20 +298,21 @@ private static ExpressionsExclusiveGateway SetupExpressionsGateway( .ReturnsAsync(formData); } - if (dataType != null) - { - appModel.Setup(a => a.GetModelType(dataType.FullName!)).Returns(dataType); - } - var frontendSettings = Options.Create(new FrontEndSettings()); - var layoutStateInit = new LayoutEvaluatorStateInitializer(resources.Object, frontendSettings); - return new ExpressionsExclusiveGateway( - layoutStateInit, - resources.Object, - appModel.Object, - appMetadata.Object, - dataClient.Object + + _httpContextAccessor.SetupGet(hca => hca.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); + + var layoutStateInit = new LayoutEvaluatorStateInitializer( + _resources.Object, + frontendSettings, + new CachedFormDataAccessor( + _dataClient.Object, + _appMetadata.Object, + _appModel.Object, + _httpContextAccessor.Object + ) ); + return new ExpressionsExclusiveGateway(layoutStateInit); } private static string LayoutSetsToString(LayoutSets layoutSets) => diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs index 12faf1d37..317447f86 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs @@ -14,34 +14,21 @@ namespace Altinn.App.Core.Tests.Internal.Process.ProcessTasks.Common; public class ProcessTaskFinalizerTests { - private readonly Mock _appMetadataMock; - private readonly Mock _dataClientMock; - private readonly Mock _appModelMock; - private readonly Mock _appResourcesMock; - private readonly Mock _layoutEvaluatorStateInitializerMock; - private readonly IOptions _appSettingsMock; + private readonly Mock _appMetadataMock = new(); + private readonly Mock _dataClientMock = new(); + private readonly Mock _appModelMock = new(); + private readonly Mock _layoutEvaluatorStateInitializerMock = new(); + private readonly IOptions _appSettings = Options.Create(new AppSettings()); private readonly ProcessTaskFinalizer _processTaskFinalizer; public ProcessTaskFinalizerTests() { - _appMetadataMock = new Mock(); - _dataClientMock = new Mock(); - _appModelMock = new Mock(); - _appResourcesMock = new Mock(); - var frontendSettingsMock = new Mock>(); - _layoutEvaluatorStateInitializerMock = new Mock( - MockBehavior.Strict, - [_appResourcesMock.Object, frontendSettingsMock.Object] - ); - _appSettingsMock = Options.Create(new AppSettings()); - _processTaskFinalizer = new ProcessTaskFinalizer( _appMetadataMock.Object, _dataClientMock.Object, _appModelMock.Object, - _appResourcesMock.Object, _layoutEvaluatorStateInitializerMock.Object, - _appSettingsMock + _appSettings ); } @@ -49,19 +36,23 @@ public ProcessTaskFinalizerTests() public async Task Finalize_WithValidInputs_ShouldCallCorrectMethods() { // Arrange - Instance instance = CreateInstance(); - - instance.Data = - [ - new DataElement + Instance instance = new Instance() + { + Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", + AppId = "ttd/test", + Process = new ProcessState { - Id = Guid.NewGuid().ToString(), - References = - [ - new Reference { ValueType = ReferenceType.Task, Value = instance.Process.CurrentTask.ElementId } - ] - } - ]; + CurrentTask = new ProcessElementInfo { AltinnTaskType = "signing", ElementId = "EndEvent", }, + }, + Data = + [ + new DataElement + { + Id = Guid.NewGuid().ToString(), + References = [new Reference { ValueType = ReferenceType.Task, Value = "EndEvent" }] + } + ] + }; var applicationMetadata = new ApplicationMetadata(instance.AppId) { @@ -76,17 +67,4 @@ public async Task Finalize_WithValidInputs_ShouldCallCorrectMethods() // Assert _appMetadataMock.Verify(x => x.GetApplicationMetadata(), Times.Once); } - - private static Instance CreateInstance() - { - return new Instance() - { - Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", - AppId = "ttd/test", - Process = new ProcessState - { - CurrentTask = new ProcessElementInfo { AltinnTaskType = "signing", ElementId = "EndEvent", }, - }, - }; - } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/.editorconfig b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/.editorconfig new file mode 100644 index 000000000..64e9eaaeb --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/.editorconfig @@ -0,0 +1,4 @@ +# Shared tests uses only two spaces for json formatting +[*.json] +indent_style = space +indent_size = 2 diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs index 5366cd7e0..4b7f235c1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs @@ -1,8 +1,8 @@ -using System.Text.Json.Nodes; +using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Models.Layout; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class ContextListRoot { @@ -29,7 +29,7 @@ public class ContextListRoot public LayoutModel ComponentModel { get; set; } = default!; [JsonPropertyName("dataModel")] - public JsonObject? DataModel { get; set; } + public JsonElement? DataModel { get; set; } [JsonPropertyName("expectedContexts")] public List Expected { get; set; } = default!; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index dd09a94ca..b3c353ffc 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -1,12 +1,11 @@ using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.Platform.Storage.Interface.Models; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class ExpressionTestCaseRoot { @@ -45,7 +44,10 @@ public class ExpressionTestCaseRoot public LayoutModel ComponentModel { get; set; } = default!; [JsonPropertyName("dataModel")] - public JsonObject? DataModel { get; set; } + public JsonElement? DataModel { get; set; } + + [JsonPropertyName("dataModels")] + public List? DataModels { get; set; } [JsonPropertyName("frontendSettings")] public FrontEndSettings? FrontEndSettings { get; set; } @@ -56,12 +58,30 @@ public class ExpressionTestCaseRoot [JsonPropertyName("gatewayAction")] public string? GatewayAction { get; set; } + [JsonPropertyName("profileSettings")] + public ProfileSettings? ProfileSettings { get; set; } + public override string ToString() { return $"{Filename}: {Name}"; } } +public class DataModelAndElement +{ + [JsonPropertyName("dataElement")] + public required DataElement DataElement { get; set; } + + [JsonPropertyName("data")] + public required JsonElement Data { get; set; } +} + +public class ProfileSettings +{ + [JsonPropertyName("language")] + public string? Language { get; set; } +} + public class ComponentContextForTestSpec { [JsonPropertyName("component")] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs index 9ec0925e8..b64bed7d0 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs @@ -1,8 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; +using Altinn.Platform.Storage.Interface.Models; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; /// /// Custom converter for parsing Layout files in json format to @@ -24,7 +26,7 @@ public class LayoutModelConverterFromObject : JsonConverter ); } - var componentModel = new LayoutModel(); + var pages = new Dictionary(); // Read dictionary of pages while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) @@ -47,10 +49,14 @@ public class LayoutModelConverterFromObject : JsonConverter PageComponentConverter.SetAsyncLocalPageName(pageName); var converter = new PageComponentConverter(); - componentModel.Pages[pageName] = converter.ReadNotNull(ref reader, pageName, options); + pages[pageName] = converter.ReadNotNull(ref reader, pageName, options); } - return componentModel; + return new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default", }, + Pages = pages + }; } /// diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 6ebd6da8e..4c0253c20 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,11 +1,11 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using FluentAssertions; using Xunit.Abstractions; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class TestBackendExclusiveFunctions { @@ -59,11 +59,12 @@ private void RunTestCase(string testName, string folder) _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); var state = new LayoutEvaluatorState( - new JsonDataModel(test.DataModel), + DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), test.ComponentModel, test.FrontEndSettings ?? new(), test.Instance ?? new(), - test.GatewayAction + test.GatewayAction, + test.ProfileSettings?.Language ); if (test.ExpectsFailure is not null) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs index 1abc21b23..f28888b8c 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs @@ -1,15 +1,12 @@ -using System.Reflection; -using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using FluentAssertions; using Xunit.Abstractions; -using Xunit.Sdk; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class TestContextList { @@ -65,7 +62,13 @@ private void RunTestCase(string filename, string folder) _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); - var state = new LayoutEvaluatorState(new JsonDataModel(test.DataModel), test.ComponentModel, new(), new()); + + var state = new LayoutEvaluatorState( + DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), + test.ComponentModel, + new(), + new() + ); test.ParsingException.Should().BeNull("Loading of test failed"); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 0ae39cd82..95b3d448a 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -1,11 +1,11 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using FluentAssertions; using Xunit.Abstractions; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class TestFunctions { @@ -39,6 +39,10 @@ public TestFunctions(ITestOutputHelper output) [SharedTest("concat")] public void Concat_Theory(string testName, string folder) => RunTestCase(testName, folder); + [Theory] + [SharedTest("language")] + public void Language_Theory(string testName, string folder) => RunTestCase(testName, folder); + [Theory] [SharedTest("contains")] public void Contains_Theory(string testName, string folder) => RunTestCase(testName, folder); @@ -47,6 +51,10 @@ public TestFunctions(ITestOutputHelper output) [SharedTest("dataModel")] public void DataModel_Theory(string testName, string folder) => RunTestCase(testName, folder); + [Theory] + [SharedTest("dataModelMultiple")] + public void DataModelMultiple_Theory(string testName, string folder) => RunTestCase(testName, folder); + [Theory] [SharedTest("endsWith")] public void EndsWith_Theory(string testName, string folder) => RunTestCase(testName, folder); @@ -151,11 +159,18 @@ private void RunTestCase(string testName, string folder) _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); + + var dataModel = test.DataModels is null + ? DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement) + : DynamicClassBuilder.DataModelFromJsonDocument(test.DataModels); + var state = new LayoutEvaluatorState( - new JsonDataModel(test.DataModel), + dataModel, test.ComponentModel, test.FrontEndSettings ?? new(), - test.Instance ?? new() + test.Instance ?? new(), + test.GatewayAction, + test.ProfileSettings?.Language ); if (test.ExpectsFailure is not null) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index cfc1f5939..90bd4db39 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,14 +1,11 @@ -using System.Reflection; -using System.Runtime.CompilerServices; using System.Text.Json; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using FluentAssertions; using Xunit.Abstractions; -using Xunit.Sdk; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; public class TestInvalid { @@ -34,7 +31,7 @@ public void Simple_Theory(string testName, string folder) { var test = JsonSerializer.Deserialize(testCase.RawJson!, _jsonSerializerOptions)!; var state = new LayoutEvaluatorState( - new JsonDataModel(test.DataModel), + DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), test.ComponentModel, test.FrontEndSettings ?? new(), test.Instance ?? new() diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group-other-row.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group-other-row.json index f615857de..a09b6e8e1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group-other-row.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group-other-row.json @@ -63,7 +63,8 @@ "Ansatte": [ { "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": false }, { "Navn": "Per", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group.json index 60fc6325f..e46435a70 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hidden-in-group.json @@ -63,7 +63,8 @@ "Ansatte": [ { "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": true }, { "Navn": "Per", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hide-group-component.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hide-group-component.json index ab8b1ed77..35379e9f4 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hide-group-component.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/hide-group-component.json @@ -77,7 +77,8 @@ "Ansatte": [ { "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": false }, { "Navn": "Per", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json index b1b23acaa..c11813c86 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json @@ -2,16 +2,25 @@ "name": "Looking up an array returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": [ - { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "other": "DEF" + "data": { + "a": [ + { + "value": "ABC", + "other": null + }, + { + "other": "DEF" + } + ] } - ] - }, + } + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json index d211b3a24..01e2cbcc5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json @@ -51,16 +51,24 @@ } } }, - "dataModel": { - "Mennesker": [ - { - "Navn": "Kåre", - "Alder": 24 + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Arild", - "Alder": 14 + "data": { + "Mennesker": [ + { + "Navn": "Kåre", + "Alder": 24 + }, + { + "Navn": "Arild", + "Alder": 14 + } + ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json index c5edf05c6..c07e25a54 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json @@ -66,34 +66,42 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "Navn": "Kaare", - "Alder": 55 - }, - { - "Navn": "Per", - "Alder": 24 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "Navn": "Arne", - "Alder": 24 + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 55 + }, + { + "Navn": "Per", + "Alder": 24 + } + ] }, { - "Navn": "Vidar", - "Alder": 14 + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 24 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json index 2b686075b..82eba95cc 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json @@ -66,34 +66,42 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "Navn": "Kaare", - "Alder": 24 - }, - { - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "Navn": "Arne", - "Alder": 26 + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 25 + } + ] }, { - "Navn": "Vidar", - "Alder": 14 + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 26 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json index 1c3986e5b..b11d5fd92 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json @@ -66,34 +66,42 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "Navn": "Kaare", - "Alder": 24 - }, - { - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "Navn": "Arne", - "Alder": 26 + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 25 + } + ] }, { - "Navn": "Vidar", - "Alder": 14 + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 26 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json index 67adb67ad..abd271cda 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json @@ -66,34 +66,42 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "Navn": "Kaare", - "Alder": 24 - }, - { - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "Navn": "Arne", - "Alder": 26 + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 25 + } + ] }, { - "Navn": "Vidar", - "Alder": 14 + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 26 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json index 12e613432..265789ec6 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json @@ -51,16 +51,24 @@ } } }, - "dataModel": { - "Mennesker": [ - { - "Navn": "Kåre", - "Alder": 24 + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Arild", - "Alder": 14 + "data": { + "Mennesker": [ + { + "Navn": "Kåre", + "Alder": 24 + }, + { + "Navn": "Arild", + "Alder": 14 + } + ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json index 44b05b56c..40e1c2970 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json @@ -66,34 +66,42 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "Navn": "Kaare", - "Alder": 24 - }, - { - "Navn": "Per", - "Alder": 24 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "Navn": "Arne", - "Alder": 24 + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 24 + } + ] }, { - "Navn": "Vidar", - "Alder": 14 + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 24 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json index 107636614..cec55487e 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json @@ -2,9 +2,17 @@ "name": "Looking up null returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": null - }, + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": null + } + } + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json index 698f605d5..ea5707692 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json @@ -2,11 +2,19 @@ "name": "Looking up null", "expression": ["dataModel", null], "expectsFailure": "Cannot lookup dataModel null", - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json index 5d9f649eb..2302446c0 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json @@ -2,11 +2,19 @@ "name": "Looking up an object returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json index 657f3f249..6f52e19a6 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json @@ -2,14 +2,22 @@ "name": "Simple lookup equals other lookup", "expression": ["equals", ["dataModel", "a.value"], ["dataModel", "b.value"]], "expects": true, - "dataModel": { - "a": { - "value": "hello world" - }, - "b": { - "value": "hello world" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "hello world" + }, + "b": { + "value": "hello world" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json index bf791f569..4c44d6220 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json @@ -2,11 +2,19 @@ "name": "Simple lookup for non-existing key equals null (1)", "expression": ["dataModel", "a.value.length"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json index ccc645b14..1ebed41a2 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json @@ -2,11 +2,19 @@ "name": "Simple lookup for non-existing key equals null (2)", "expression": ["dataModel", "a.otherValue.Count"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json index 550fa71c1..004f25aa7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json @@ -2,11 +2,19 @@ "name": "Simple lookup", "expression": ["dataModel", "a.value"], "expects": "ABC", - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json new file mode 100644 index 000000000..ba6f665e7 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json @@ -0,0 +1,55 @@ +{ + "name": "Component lookup with binding to non-default model", + "expression": [ + "component", + "current-component" + ], + "expects": "valueFromNonDefaultModel", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-default", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json new file mode 100644 index 000000000..890e133c0 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json @@ -0,0 +1,55 @@ +{ + "name": "Lookup non non existant model returns null", + "expression": [ + "component", + "current-component" + ], + "expects": null, + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-existant", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json new file mode 100644 index 000000000..412ae68b7 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json @@ -0,0 +1,50 @@ +{ + "name": "dataModel non default data type lookup", + "expression": [ + "dataModel", + "a.value", + "non-defualt" + ], + "expects": "valueFromNonDefaultModel", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-defualt" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Paragraph" + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json new file mode 100644 index 000000000..41bef2618 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json @@ -0,0 +1,56 @@ +{ + "name": "dataModel non-existant datamodel reference", + "expression": [ + "dataModel", + "a.value", + "non-existant" + ], + "expects": null, + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-default", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-nb-if-not-set.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-nb-if-not-set.json similarity index 100% rename from test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-nb-if-not-set.json rename to test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-nb-if-not-set.json diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-profile-settings-preference.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-profile-settings-preference.json similarity index 100% rename from test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-profile-settings-preference.json rename to test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-profile-settings-preference.json diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-selected-language.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-selected-language.json similarity index 100% rename from test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/up-for-evaluation/functions/language/should-return-selected-language.json rename to test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/language/should-return-selected-language.json diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 1da35b3cb..633358164 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -2,30 +2,75 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Moq; -namespace Altinn.App.Core.Tests.LayoutExpressions; +namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests; public static class LayoutTestUtils { private static readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); + private const string Org = "ttd"; + private const string App = "test"; + private const string AppId = $"{Org}/{App}"; + private const int InstanceOwnerPartyId = 134; + private static readonly Guid _instanceGuid = Guid.Parse("12345678-1234-1234-1234-123456789012"); + private static readonly Guid _dataGuid = Guid.Parse("12345678-1234-1234-1234-123456789013"); + private const string DataTypeId = "default"; + private const string ClassRef = "NoClass"; + private const string TaskId = "Task_1"; + + private static readonly ApplicationMetadata _applicationMetadata = + new(AppId) + { + DataTypes = + [ + new DataType() + { + Id = DataTypeId, + TaskId = TaskId, + AppLogic = new() { ClassRef = ClassRef } + } + ] + }; + + private static readonly Instance _instance = + new() + { + Id = $"{InstanceOwnerPartyId}/{_instanceGuid}", + AppId = AppId, + Org = Org, + InstanceOwner = new() { PartyId = InstanceOwnerPartyId.ToString() }, + Data = [new DataElement() { Id = _dataGuid.ToString(), DataType = "default", }] + }; + public static async Task GetLayoutModelTools(object model, string folder) { var services = new ServiceCollection(); - var data = new Mock(); - data.Setup(d => d.GetFormData(default, default!, default!, default!, default, default)).ReturnsAsync(model); - services.AddTransient((sp) => data.Object); + var appMetadata = new Mock(MockBehavior.Strict); + + appMetadata.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); + var appModel = new Mock(MockBehavior.Strict); + var modelType = model.GetType(); + appModel.Setup(am => am.GetModelType(ClassRef)).Returns(modelType); + + var data = new Mock(MockBehavior.Strict); + data.Setup(d => d.GetFormData(_instanceGuid, modelType, Org, App, InstanceOwnerPartyId, _dataGuid)) + .ReturnsAsync(model); + services.AddSingleton(data.Object); var resources = new Mock(); - var layoutModel = new LayoutModel(); + var pages = new Dictionary(); var layoutsPath = Path.Join("LayoutExpressions", "FullTests", folder); foreach (var layoutFile in Directory.GetFiles(layoutsPath, "*.json")) { @@ -34,22 +79,32 @@ public static async Task GetLayoutModelTools(object model, PageComponentConverter.SetAsyncLocalPageName(pageName); - layoutModel.Pages[pageName] = JsonSerializer.Deserialize( - layout.RemoveBom(), - _jsonSerializerOptions - )!; + pages[pageName] = JsonSerializer.Deserialize(layout.RemoveBom(), _jsonSerializerOptions)!; } + var layoutModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = DataTypeId, }, + Pages = pages + }; + + resources.Setup(r => r.GetLayoutModelForTask(TaskId)).Returns(layoutModel); - resources.Setup(r => r.GetLayoutModel(null)).Returns(layoutModel); + services.AddSingleton(resources.Object); + services.AddSingleton(appMetadata.Object); + services.AddSingleton(appModel.Object); + services.AddScoped(); + services.AddScoped(); + + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.SetupGet(c => c.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); + services.AddSingleton(httpContextAccessorMock.Object); - services.AddTransient((sp) => resources.Object); - services.AddTransient(); services.AddOptions().Configure(fes => fes.Add("test", "value")); var serviceProvider = services.BuildServiceProvider(validateScopes: true); + using var scope = serviceProvider.CreateScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); - var initializer = serviceProvider.GetRequiredService(); - - return await initializer.Init(new Instance { Id = "123/" + Guid.NewGuid() }, model, null); + return await initializer.Init(_instance, TaskId); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index 6ab3b36e7..a7a692b57 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -1,6 +1,7 @@ #nullable disable using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using FluentAssertions; namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test1; @@ -46,7 +47,7 @@ public async Task RemoveData_WhenPageExpressionIsTrue() "Test1" ); var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); - hidden.Should().BeEquivalentTo(["some.data.binding2"]); + hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data.binding2", DataType = "default" }]); } [Fact] @@ -62,7 +63,7 @@ public async Task RunLayoutValidationsForRequired_InvalidComponentHidden_Returns }, "Test1" ); - var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state, dataElementId: "dummy"); + var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state); validationIssues.Should().BeEmpty(); } @@ -79,7 +80,7 @@ public async Task RunLayoutValidationsForRequired_InvalidComponentHidden_Returns }, "Test1" ); - var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state, dataElementId: "dummy"); + var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state); validationIssues .Should() .BeEquivalentTo(new object[] { new { Code = "required", Field = "some.data.binding3" } }); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index e9c5d0b03..92c8436f1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -1,7 +1,7 @@ -#nullable disable using System.Text.Json.Serialization; -using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using FluentAssertions; namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test2; @@ -48,7 +48,11 @@ public async Task RemoveWholeGroup() hidden .Should() .BeEquivalentTo( - new List { "some.data[0].binding2", "some.data[1].binding", "some.data[1].binding2" } + [ + new ModelBinding { Field = "some.data[0].binding2", DataType = "default" }, + new ModelBinding { Field = "some.data[1].binding", DataType = "default" }, + new ModelBinding { Field = "some.data[1].binding2", DataType = "default" } + ] ); // Verify before removing data @@ -85,33 +89,33 @@ public async Task RemoveSingleRow() ); var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); - hidden.Should().BeEquivalentTo(new List { "some.data[1].binding2" }); + hidden.Should().BeEquivalentTo([new ModelBinding() { Field = "some.data[1].binding2", DataType = "default" }]); } } public class DataModel { [JsonPropertyName("some")] - public Some Some { get; set; } + public Some? Some { get; set; } } public class Some { [JsonPropertyName("notRepeating")] - public string NotRepeating { get; set; } + public string? NotRepeating { get; set; } [JsonPropertyName("data")] - public List Data { get; set; } + public List? Data { get; set; } } public class Data { [JsonPropertyName("binding")] - public string Binding { get; set; } + public string? Binding { get; set; } [JsonPropertyName("binding2")] public int Binding2 { get; set; } [JsonPropertyName("binding3")] - public string Binding3 { get; set; } + public string? Binding3 { get; set; } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index e4bbb8bc5..5c596a221 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -1,7 +1,7 @@ -#nullable disable using System.Text.Json.Serialization; -using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using FluentAssertions; namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test3; @@ -51,7 +51,7 @@ public async Task RemoveRowDataFromGroup() var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists - hidden.Should().BeEquivalentTo(new List { "some.data[2]" }); + hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); // Verify before removing data data.Some.Data.Should().HaveCount(3); @@ -108,7 +108,7 @@ public async Task RemoveRowFromGroup() var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists - hidden.Should().BeEquivalentTo(new List { "some.data[2]" }); + hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); // Verify before removing data data.Some.Data.Should().HaveCount(3); @@ -133,26 +133,26 @@ public async Task RemoveRowFromGroup() public class DataModel { [JsonPropertyName("some")] - public Some Some { get; set; } + public Some? Some { get; set; } } public class Some { [JsonPropertyName("notRepeating")] - public string NotRepeating { get; set; } + public string? NotRepeating { get; set; } [JsonPropertyName("data")] - public List Data { get; set; } + public List? Data { get; set; } } public class Data { [JsonPropertyName("binding")] - public string Binding { get; set; } + public string? Binding { get; set; } [JsonPropertyName("binding2")] public int Binding2 { get; set; } [JsonPropertyName("binding3")] - public string Binding3 { get; set; } + public string? Binding3 { get; set; } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs index 9fa2b41c7..130b04a75 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs @@ -1,58 +1,59 @@ +using System.Collections; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; -using Altinn.App.Core.Tests.Helpers; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; +using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Newtonsoft.Json; using JsonSerializer = System.Text.Json.JsonSerializer; -namespace Altinn.App.Core.Tests.LayoutExpressions.CSharpTests; +namespace Altinn.App.Core.Tests.LayoutExpressions; public class TestDataModel { + private readonly DataElement _dataElement = new() { DataType = "default" }; + [Fact] public void TestSimpleGet() { var model = new Model { Name = new() { Value = "myValue" } }; - var modelHelper = new DataModel(model); - modelHelper.GetModelData("does.not.exist", default).Should().BeNull(); - modelHelper.GetModelData("name.value", default).Should().Be(model.Name.Value); + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, (object)model)]); + modelHelper.GetModelData("does.not.exist").Should().BeNull(); + modelHelper.GetModelData("name.value").Should().Be(model.Name.Value); modelHelper.GetModelData("name.value", [1, 2, 3]).Should().Be(model.Name.Value); } [Fact] public void AttributeNoAttriubteCaseSensitive() { - var modelHelper = new DataModel(new Model { NoAttribute = "asdfsf559", }); - modelHelper.GetModelData("NOATTRIBUTE", default).Should().BeNull("data model lookup is case sensitive"); - modelHelper.GetModelData("noAttribute", default).Should().BeNull(); - modelHelper.GetModelData("NoAttribute", default).Should().Be("asdfsf559"); + var model = new Model { NoAttribute = "asdfsf559" }; + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, (object)model)]); + modelHelper.GetModelData("NOATTRIBUTE").Should().BeNull("data model lookup is case sensitive"); + modelHelper.GetModelData("noAttribute").Should().BeNull(); + modelHelper.GetModelData("NoAttribute").Should().Be("asdfsf559"); } [Fact] public void NewtonsoftAttributeWorks() { - var modelHelper = new DataModel(new Model { OnlyNewtonsoft = "asdfsf559", }); - modelHelper - .GetModelData("OnlyNewtonsoft", default) - .Should() - .BeNull("Attribute should win over property when set"); - modelHelper.GetModelData("ONlyNewtonsoft", default).Should().BeNull(); - modelHelper.GetModelData("onlyNewtonsoft", default).Should().Be("asdfsf559"); + var modelHelper = new DataModel( + [KeyValuePair.Create(_dataElement, (object)new Model { OnlyNewtonsoft = "asdfsf559", })] + ); + modelHelper.GetModelData("OnlyNewtonsoft").Should().BeNull("Attribute should win over property when set"); + modelHelper.GetModelData("ONlyNewtonsoft").Should().BeNull(); + modelHelper.GetModelData("onlyNewtonsoft").Should().Be("asdfsf559"); } [Fact] public void SystemTextJsonAttributeWorks() { - var modelHelper = new DataModel(new Model { OnlySystemTextJson = "asdfsf559", }); - modelHelper - .GetModelData("OnlySystemTextJson", default) - .Should() - .BeNull("Attribute should win over property when set"); - modelHelper.GetModelData("onlysystemtextjson", default).Should().BeNull(); - modelHelper.GetModelData("onlySystemTextJson", default).Should().Be("asdfsf559"); + var modelHelper = new DataModel( + [KeyValuePair.Create(_dataElement, new Model { OnlySystemTextJson = "asdfsf559" })] + ); + modelHelper.GetModelData("OnlySystemTextJson").Should().BeNull("Attribute should win over property when set"); + modelHelper.GetModelData("onlysystemtextjson").Should().BeNull(); + modelHelper.GetModelData("onlySystemTextJson").Should().Be("asdfsf559"); } [Fact] @@ -70,25 +71,25 @@ public void RecursiveLookup() new() { Name = new() { Value = "Dolly Duck" } } } }; - IDataModelAccessor modelHelper = new DataModel(model); - modelHelper.GetModelData("friends.name.value", default).Should().BeNull(); - modelHelper.GetModelData("friends[0].name.value", default).Should().Be("Donald Duck"); + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); + modelHelper.GetModelData("friends.name.value").Should().BeNull(); + modelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); modelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); - modelHelper.GetModelData("friends[0].age", default).Should().Be(123); + modelHelper.GetModelData("friends[0].age").Should().Be(123); modelHelper.GetModelData("friends.age", [0]).Should().Be(123); - modelHelper.GetModelData("friends[1].name.value", default).Should().Be("Dolly Duck"); + modelHelper.GetModelData("friends[1].name.value").Should().Be("Dolly Duck"); modelHelper.GetModelData("friends.name.value", [1]).Should().Be("Dolly Duck"); // Run the same tests with JsonDataModel - var doc = JsonSerializer.Deserialize(JsonSerializer.Serialize(model)); - modelHelper = new JsonDataModel(doc); - modelHelper.GetModelData("friends.name.value", default).Should().BeNull(); - modelHelper.GetModelData("friends[0].name.value", default).Should().Be("Donald Duck"); - modelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); - modelHelper.GetModelData("friends[0].age", default).Should().Be(123); - modelHelper.GetModelData("friends.age", [0]).Should().Be(123); - modelHelper.GetModelData("friends[1].name.value", default).Should().Be("Dolly Duck"); - modelHelper.GetModelData("friends.name.value", [1]).Should().Be("Dolly Duck"); + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); + var jsonModelHelper = DynamicClassBuilder.DataModelFromJsonDocument(doc.RootElement); + jsonModelHelper.GetModelData("friends.name.value").Should().BeNull(); + jsonModelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); + jsonModelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); + jsonModelHelper.GetModelData("friends[0].age").Should().Be(123); + jsonModelHelper.GetModelData("friends.age", [0]).Should().Be(123); + jsonModelHelper.GetModelData("friends[1].name.value").Should().Be("Dolly Duck"); + jsonModelHelper.GetModelData("friends.name.value", [1]).Should().Be("Dolly Duck"); } [Fact] @@ -102,6 +103,27 @@ public void DoubleRecursiveLookup() { Name = new() { Value = "Donald Duck" }, Age = 123, + Friends = new List + { + new() + { + Name = new() { Value = "Onkel Skrue", }, + Age = 2022, + Friends = new List + { + new() + { + Name = new() { Value = "LykkeTiøringen" }, + Age = 23, + }, + new() + { + Name = new() { Value = "Madam mim" }, + Age = 23, + } + }, + } + }, }, new() { @@ -131,8 +153,8 @@ public void DoubleRecursiveLookup() } }; - IDataModelAccessor modelHelper = new DataModel(model); - modelHelper.GetModelData("friends[1].friends[0].name.value", default).Should().Be("Onkel Skrue"); + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); + modelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); modelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); modelHelper .GetModelData("friends[1].friends.name.value", [1, 0]) @@ -148,22 +170,22 @@ public void DoubleRecursiveLookup() modelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); // Run the same tests with JsonDataModel - var doc = JsonSerializer.Deserialize(JsonSerializer.Serialize(model)); - modelHelper = new JsonDataModel(doc); - modelHelper.GetModelData("friends[1].friends[0].name.value", default).Should().Be("Onkel Skrue"); - modelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); - modelHelper + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); + var jsonModelHelper = DynamicClassBuilder.DataModelFromJsonDocument(doc.RootElement); + jsonModelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); + jsonModelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); + jsonModelHelper .GetModelData("friends[1].friends.name.value", [1, 0]) .Should() .BeNull("context indexes should not be used after literal index is used"); - modelHelper.GetModelData("friends[1].friends.name.value", [1]).Should().BeNull(); - modelHelper.GetModelData("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); - modelHelper.GetModelDataCount("friends[1].friends", Array.Empty()).Should().Be(1); - modelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); - modelHelper.GetModelDataCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); - modelHelper.GetModelDataCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); - modelHelper.GetModelDataCount("friends.friends.friends", [1, 0, 0]).Should().Be(2); - modelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); + jsonModelHelper.GetModelData("friends[1].friends.name.value", [1]).Should().BeNull(); + jsonModelHelper.GetModelData("friends.friends[0].name.value", [1, 4, 5, 7]).Should().Be("Onkel Skrue"); + jsonModelHelper.GetModelDataCount("friends[1].friends", Array.Empty()).Should().Be(1); + jsonModelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); + jsonModelHelper.GetModelDataCount("friends[1].friends.friends", [1, 0, 0]).Should().BeNull(); + jsonModelHelper.GetModelDataCount("friends[1].friends[0].friends", [1, 0, 0]).Should().Be(2); + jsonModelHelper.GetModelDataCount("friends.friends.friends", [1, 0, 0]).Should().Be(2); + jsonModelHelper.GetModelDataCount("friends.friends", [1]).Should().Be(1); } [Fact] @@ -190,7 +212,7 @@ public void TestRemoveFields() } } }; - IDataModelAccessor modelHelper = new DataModel(model); + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); model.Id.Should().Be(2); modelHelper.RemoveField("id", RowRemovalOption.SetToNull); model.Id.Should().Be(default); @@ -271,11 +293,11 @@ public void TestRemoveRows() } } }; - var serializedModel = System.Text.Json.JsonSerializer.Serialize(model); + var serializedModel = JsonSerializer.Serialize(model); // deleteRows = false - var model1 = System.Text.Json.JsonSerializer.Deserialize(serializedModel)!; - IDataModelAccessor modelHelper1 = new DataModel(model1); + var model1 = JsonSerializer.Deserialize(serializedModel)!; + var modelHelper1 = new DataModel([KeyValuePair.Create(_dataElement, model1)]); modelHelper1.RemoveField("friends[0].friends[0]", RowRemovalOption.SetToNull); model1.Friends![0].Friends![0].Should().BeNull(); @@ -288,8 +310,8 @@ public void TestRemoveRows() model1.Friends[2].Name!.Value.Should().Be("Tredje venn"); // deleteRows = true - var model2 = System.Text.Json.JsonSerializer.Deserialize(serializedModel)!; - IDataModelAccessor modelHelper2 = new DataModel(model2); + var model2 = JsonSerializer.Deserialize(serializedModel)!; + var modelHelper2 = new DataModel([KeyValuePair.Create(_dataElement, model2)]); modelHelper2.RemoveField("friends[0].friends[0]", RowRemovalOption.DeleteRow); model2.Friends![0].Friends!.Count.Should().Be(2); @@ -304,11 +326,16 @@ public void TestRemoveRows() public void TestErrorCases() { var modelHelper = new DataModel( - new Model - { - Id = 3, - Friends = new List() { new() { Name = new() { Value = "Ole" }, } } - } + [ + KeyValuePair.Create( + _dataElement, + new Model() + { + Id = 3, + Friends = new List() { new() { Name = new() { Value = "Ole" }, } } + } + ) + ] ); modelHelper.Invoking(m => m.GetModelData(".")).Should().Throw().WithMessage("*empty part*"); modelHelper.GetModelData("friends[0]").Should().BeOfType().Which.Name?.Value.Should().Be("Ole"); @@ -330,13 +357,18 @@ public void TestErrorCases() [Fact] public void TestEdgeCaseWithNonGenericEnumerableForCoverage() { - // Test with erronious model with non-generic IEnumerable (special error for code coverage) + // Test with erroneous model with non-generic IEnumerable (special error for code coverage) var modelHelper = new DataModel( - new - { - // ArrayList is not supported as a data model - friends = new System.Collections.ArrayList() { 1, 2, 3 }, - } + [ + KeyValuePair.Create( + _dataElement, + new + { + // ArrayList is not supported as a data model + friends = new ArrayList { 1, 2, 3 } + } + ) + ] ); modelHelper .Invoking(m => m.AddIndicies("friends", [0])) @@ -348,31 +380,38 @@ public void TestEdgeCaseWithNonGenericEnumerableForCoverage() [Fact] public void TestAddIndicies() { - IDataModelAccessor modelHelper = new DataModel( - new Model - { - Id = 3, - Friends = new List() { new() { Name = new() { Value = "Ole" }, } } - } + var modelHelper = new DataModel( + [ + KeyValuePair.Create( + _dataElement, + new Model + { + Id = 3, + Friends = new List() { new() { Name = new() { Value = "Ole" }, } } + } + ) + ] ); // Plain add indicies - modelHelper.AddIndicies("friends.friends", [0, 1]).Should().Be("friends[0].friends[1]"); + modelHelper.AddIndicies("friends.friends", [0, 1]).Field.Should().Be("friends[0].friends[1]"); // Ignore extra indicies - modelHelper.AddIndicies("friends.friends", [0, 1, 4, 6]).Should().Be("friends[0].friends[1]"); + modelHelper.AddIndicies("friends.friends", [0, 1, 4, 6]).Field.Should().Be("friends[0].friends[1]"); // Don't add indicies if they are specified in input - modelHelper.AddIndicies("friends[3]", [0]).Should().Be("friends[3]"); + modelHelper.AddIndicies("friends[3]", [0]).Field.Should().Be("friends[3]"); // First index is ignored if it is explicit - modelHelper.AddIndicies("friends[0].friends", [2, 3]).Should().Be("friends[0].friends[3]"); + modelHelper.AddIndicies("friends[0].friends", [2, 3]).Field.Should().Be("friends[0].friends[3]"); } [Fact] public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() { - IDataModelAccessor modelHelper = new DataModel(new Model { Id = 3, }); + var modelHelper = new DataModel( + [KeyValuePair.Create(_dataElement, new Model { Id = 3, })] + ); // Throws because id is not indexable modelHelper @@ -385,18 +424,18 @@ public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() [Fact] public void RemoveField_WhenValueDoesNotExist_DoNothing() { - var modelHelper = new DataModel(new Model()); + var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, new Model())]); // real fields works, no error modelHelper.RemoveField("id", RowRemovalOption.SetToNull); - // non-existant-fields works, no error + // non-existent-fields works, no error modelHelper.RemoveField("doesNotExist", RowRemovalOption.SetToNull); - // non-existant-fields in subfield works, no error + // non-existent-fields in subfield works, no error modelHelper.RemoveField("friends.doesNotExist", RowRemovalOption.SetToNull); - // non-existant-fields in subfield works, no error + // non-existent-fields in subfield works, no error modelHelper.RemoveField("friends[0].doesNotExist", RowRemovalOption.SetToNull); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs new file mode 100644 index 000000000..c0791ef16 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs @@ -0,0 +1,153 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; + +/// +/// Written by chatGPT +/// +/// Creates real C# classes dynamically so that we can use custom classes for our shared tests +/// +public class DynamicClassBuilder +{ + public static Type CreateClassFromJson(JsonDocument jsonDocument) + { + var jsonObject = jsonDocument.RootElement; + if (jsonObject.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("JsonDocument must be an object at the root level."); + } + + return CreateClassFromJsonElement(jsonObject, "DynamicClass"); + } + + private static Type CreateClassFromJsonElement(JsonElement jsonObject, string typeName) + { + AssemblyName assemblyName = new AssemblyName(typeName + "Assembly"); + AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly( + assemblyName, + AssemblyBuilderAccess.Run + ); + ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule"); + TypeBuilder typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Public); + + foreach (var property in jsonObject.EnumerateObject()) + { + Type propertyType = GetTypeFromJsonElement(property.Value, property.Name, moduleBuilder); + CreateProperty(typeBuilder, property.Name, propertyType); + } + + return typeBuilder.CreateTypeInfo().AsType(); + } + + private static void CreateProperty(TypeBuilder typeBuilder, string propertyName, Type propertyType) + { + FieldBuilder fieldBuilder = typeBuilder.DefineField("_" + propertyName, propertyType, FieldAttributes.Private); + + PropertyBuilder propertyBuilder = typeBuilder.DefineProperty( + propertyName, + PropertyAttributes.HasDefault, + propertyType, + null + ); + + MethodBuilder getPropMthdBldr = typeBuilder.DefineMethod( + "get_" + propertyName, + MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig, + propertyType, + Type.EmptyTypes + ); + ILGenerator getIl = getPropMthdBldr.GetILGenerator(); + + getIl.Emit(OpCodes.Ldarg_0); + getIl.Emit(OpCodes.Ldfld, fieldBuilder); + getIl.Emit(OpCodes.Ret); + + MethodBuilder setPropMthdBldr = typeBuilder.DefineMethod( + "set_" + propertyName, + MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig, + null, + new Type[] { propertyType } + ); + + ILGenerator setIl = setPropMthdBldr.GetILGenerator(); + + setIl.Emit(OpCodes.Ldarg_0); + setIl.Emit(OpCodes.Ldarg_1); + setIl.Emit(OpCodes.Stfld, fieldBuilder); + setIl.Emit(OpCodes.Ret); + + propertyBuilder.SetGetMethod(getPropMthdBldr); + propertyBuilder.SetSetMethod(setPropMthdBldr); + } + + private static Type GetTypeFromJsonElement(JsonElement element, string propertyName, ModuleBuilder moduleBuilder) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return typeof(string); + case JsonValueKind.Number: + return typeof(double?); // Adjust based on your needs (int, float, etc.) + case JsonValueKind.True: + case JsonValueKind.False: + return typeof(bool?); + case JsonValueKind.Object: + return CreateClassFromJsonElement(element, propertyName + "Type"); + case JsonValueKind.Array: + var arrayType = GetArrayType(element, propertyName, moduleBuilder); + return typeof(List<>).MakeGenericType(arrayType); + default: + return typeof(object); + } + } + + private static Type GetArrayType(JsonElement arrayElement, string propertyName, ModuleBuilder moduleBuilder) + { + if (arrayElement.GetArrayLength() == 0) + { + return typeof(object); + } + + var firstElement = arrayElement[0]; + return GetTypeFromJsonElement(firstElement, propertyName + "Item", moduleBuilder); + } + + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions() + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private static object DataObjectFromJsonDocument(JsonElement doc) + { + var type = CreateClassFromJsonElement(doc, "DynamicClass"); + + var instance = doc.Deserialize( + type, + _options // Ensure that we error if the created class is missing properties (it only looks at the first item of arrays) + )!; + return instance; + } + + public static DataModel DataModelFromJsonDocument(JsonElement doc, DataElement? dataElement = null) + { + object instance = DataObjectFromJsonDocument(doc); + return new DataModel( + [KeyValuePair.Create(dataElement ?? new DataElement() { DataType = "default" }, instance)] + ); + } + + public static DataModel DataModelFromJsonDocument(List dataModels) + { + return new DataModel( + dataModels.Select(dataModel => + KeyValuePair.Create(dataModel.DataElement, DataObjectFromJsonDocument(dataModel.Data)) + ) + ); + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs new file mode 100644 index 000000000..e6db8445f --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; + +public class DynamicClassBuilderChatGptTests +{ + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithStringProperty() + { + string jsonString = "{\"Name\":\"John Doe\"}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("Name").Should().NotBeNull(); + dynamicType.GetProperty("Name")!.PropertyType.Should().Be(typeof(string)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var nameProperty = dynamicType.GetProperty("Name")!.GetValue(deserializedObject); + nameProperty.Should().Be("John Doe"); + } + + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithNumberProperty() + { + string jsonString = "{\"Age\":30}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("Age").Should().NotBeNull(); + dynamicType.GetProperty("Age")!.PropertyType.Should().Be(typeof(double?)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var ageProperty = dynamicType.GetProperty("Age")!.GetValue(deserializedObject); + ageProperty.Should().Be(30.0); // Note: System.Text.Json deserializes numbers as double by default + } + + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithBooleanProperty() + { + string jsonString = "{\"IsEmployed\":true}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("IsEmployed").Should().NotBeNull(); + dynamicType.GetProperty("IsEmployed")!.PropertyType.Should().Be(typeof(bool?)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var isEmployedProperty = dynamicType.GetProperty("IsEmployed")!.GetValue(deserializedObject); + isEmployedProperty.Should().Be(true); + } + + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithNestedObjectProperty() + { + string jsonString = "{\"Address\":{\"Street\":\"123 Main St\",\"City\":\"Anytown\"}}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("Address").Should().NotBeNull(); + + Type addressType = dynamicType.GetProperty("Address")!.PropertyType; + addressType.GetProperty("Street").Should().NotBeNull(); + addressType.GetProperty("Street")!.PropertyType.Should().Be(typeof(string)); + addressType.GetProperty("City").Should().NotBeNull(); + addressType.GetProperty("City")!.PropertyType.Should().Be(typeof(string)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var addressProperty = dynamicType.GetProperty("Address")!.GetValue(deserializedObject); + var streetProperty = addressType.GetProperty("Street")!.GetValue(addressProperty); + var cityProperty = addressType.GetProperty("City")!.GetValue(addressProperty); + + streetProperty.Should().Be("123 Main St"); + cityProperty.Should().Be("Anytown"); + } + + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithArrayProperty() + { + string jsonString = "{\"Numbers\":[1, 2, 3]}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("Numbers").Should().NotBeNull(); + dynamicType.GetProperty("Numbers")!.PropertyType.Should().Be(typeof(List)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var numbersProperty = dynamicType.GetProperty("Numbers")!.GetValue(deserializedObject) as List; + + numbersProperty.Should().NotBeNull(); + numbersProperty.Should().BeEquivalentTo(new List { 1.0, 2.0, 3.0 }); + } + + [Fact] + public void CreateClassFromJson_ShouldCreateClassWithNestedArrayObjectProperty() + { + string jsonString = + "{\"Addresses\":[{\"Street\":\"123 Main St\",\"City\":\"Anytown\"},{\"Street\":\"456 Elm St\",\"City\":\"Othertown\"}]}"; + JsonDocument jsonDocument = JsonDocument.Parse(jsonString); + + Type dynamicType = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + dynamicType.Should().NotBeNull(); + dynamicType.GetProperty("Addresses").Should().NotBeNull(); + + Type addressesType = dynamicType.GetProperty("Addresses")!.PropertyType; + addressesType.Should().BeAssignableTo(typeof(List<>)); + + Type addressItemType = addressesType.GetGenericArguments()[0]; + addressItemType.GetProperty("Street").Should().NotBeNull(); + addressItemType.GetProperty("Street")!.PropertyType.Should().Be(typeof(string)); + addressItemType.GetProperty("City").Should().NotBeNull(); + addressItemType.GetProperty("City")!.PropertyType.Should().Be(typeof(string)); + + // Deserialize and assert + var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); + var addressesProperty = + dynamicType.GetProperty("Addresses")!.GetValue(deserializedObject) as IEnumerable; + + // ReSharper disable once PossibleMultipleEnumeration + addressesProperty.Should().NotBeNull(); + + // ReSharper disable once PossibleMultipleEnumeration + foreach (var address in addressesProperty!) + { + var streetProperty = addressItemType.GetProperty("Street")!.GetValue(address); + var cityProperty = addressItemType.GetProperty("City")!.GetValue(address); + + streetProperty.Should().NotBeNull(); + cityProperty.Should().NotBeNull(); + } + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderTests.cs new file mode 100644 index 000000000..839eeecbc --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderTests.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; + +public class DynamicClassBuilderTests +{ + [Fact] + public void CreateClassFromJson_GivenValidJsonDocument_ReturnsType() + { + // Arrange + var json = """{ "Property1": "string", "Property2": 42 }"""; + using var jsonDocument = JsonDocument.Parse(json); + + // Act + Type type = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + // Assert + Assert.NotNull(type); + Assert.Equal("DynamicClass", type.Name); + Assert.Equal(2, type.GetProperties().Length); + Assert.Equal(typeof(string), type.GetProperty("Property1")?.PropertyType); + Assert.Equal(typeof(double?), type.GetProperty("Property2")?.PropertyType); + + dynamic instance = JsonSerializer.Deserialize(json, type)!; + (instance.Property1 as string).Should().Be("string"); + (instance.Property2 as double?).Should().Be(42); + } + + [StringSyntax(StringSyntaxAttribute.Json)] + private const string JsonRecursive = + """{ "Property1": { "Property2": "string", "Property4":[{"AltinnRowId":"1457"}, {"AltinnRowId":"345"}] }, "Property2": [2,4,7] }"""; + + [Fact] + public void CreateClassFromJson_GivenStructureWithRecursiveTypes_ReturnsType() + { + // Arrange + using var jsonDocument = JsonDocument.Parse(JsonRecursive); + + // Act + Type type = DynamicClassBuilder.CreateClassFromJson(jsonDocument); + + // Assert + Assert.NotNull(type); + Assert.Equal("DynamicClass", type.Name); + Assert.Equal(2, type.GetProperties().Length); + Assert.Equal("Property1", type.GetProperties()[0].Name); + Assert.Equal( + typeof(string), + type.GetProperty("Property1")?.PropertyType.GetProperty("Property2")?.PropertyType + ); + + dynamic instance = JsonSerializer.Deserialize(JsonRecursive, type)!; + (instance.Property1.Property2 as string).Should().Be("string"); + (instance.Property2 as List).Should().BeEquivalentTo([2, 4, 7]); + } +} From a6885363a5353dff70605c1ae6e82334e632a1e8 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 15 Aug 2024 22:57:58 +0200 Subject: [PATCH 07/63] Restructure validation to support incremental validation for multiple data models --- .../Controllers/ActionsController.cs | 134 +++++---- .../Controllers/DataController.cs | 108 +++++-- .../Controllers/ProcessController.cs | 22 +- .../Controllers/ValidateController.cs | 47 ++- .../Models/DataPatchRequestMultiple.cs | 26 ++ .../Models/DataPatchResponse.cs | 2 +- .../Models/DataPatchResponseMultiple.cs | 20 ++ .../Models/UserActionResponse.cs | 5 +- .../Extensions/ServiceCollectionExtensions.cs | 3 - .../Features/ITaskValidator.cs | 22 +- src/Altinn.App.Core/Features/IValidator.cs | 86 ++++++ .../Features/Telemetry.Data.cs | 6 + .../Features/Telemetry.Validation.cs | 55 +--- src/Altinn.App.Core/Features/Telemetry.cs | 1 + .../Default/DataAnnotationValidator.cs | 5 +- .../Default/DefaultDataElementValidator.cs | 3 +- .../Validation/Default/ExpressionValidator.cs | 3 +- ...gacyIInstanceValidatorFormDataValidator.cs | 69 +++-- .../LegacyIInstanceValidatorTaskValidator.cs | 33 ++- .../Validation/Default/RequiredValidator.cs | 2 +- .../Validation/Helpers/ModelStateHelpers.cs | 5 +- .../Wrappers/DataElementValidatorWrapper.cs | 79 +++++ .../Wrappers/FormDataValidatorWrapper.cs | 88 ++++++ .../Wrappers/TaskValidatorWrapper.cs | 49 ++++ .../Clients/Storage/TextClient.cs | 2 +- .../Internal/Data/CachedFormDataAccessor.cs | 103 +++---- .../Internal/Data/ICachedFormDataAccessor.cs | 21 -- .../Internal/Expressions/LayoutEvaluator.cs | 1 - .../LayoutEvaluatorStateInitializer.cs | 9 +- .../Internal/Patch/DataPatchResult.cs | 4 +- .../Internal/Patch/IPatchService.cs | 12 +- .../Internal/Patch/PatchService.cs | 171 ++++++----- .../Internal/Validation/IValidationService.cs | 53 ++-- .../Internal/Validation/IValidatorFactory.cs | 122 ++++++-- .../Internal/Validation/ValidationService.cs | 271 ++++++------------ .../Models/Validation/ValidationIssue.cs | 11 +- .../Validation/ValidationIssueSource.cs | 5 + .../Validation/ValidationIssueWithSource.cs | 90 ++++++ .../Controllers/DataController_PatchTests.cs | 3 +- .../Controllers/ProcessControllerTests.cs | 3 +- .../Controllers/ValidateControllerTests.cs | 197 ++++++------- .../ValidateControllerValidateDataTests.cs | 143 ++++----- ...alidateController_ValidateInstanceTests.cs | 3 +- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 265 +++++++++++++++-- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 171 ++++++++++- .../Default/DataAnnotationValidatorTests.cs | 8 +- .../Default/LegacyIValidationFormDataTests.cs | 84 +++--- .../Validators/ValidationServiceOldTests.cs | 94 +++--- .../Validators/ValidationServiceTests.cs | 229 +++++++-------- .../PatchServiceTests.Test_Ok.verified.txt | 22 +- .../Internal/Patch/PatchServiceTests.cs | 72 ++--- .../Process/Elements/AppProcessStateTests.cs | 4 +- .../ExpressionsExclusiveGatewayTests.cs | 5 +- .../CommonTests/TestFunctions.cs | 7 +- 54 files changed, 1978 insertions(+), 1080 deletions(-) create mode 100644 src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs create mode 100644 src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs create mode 100644 src/Altinn.App.Core/Features/IValidator.cs create mode 100644 src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs create mode 100644 src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs create mode 100644 src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs delete mode 100644 src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs create mode 100644 src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index 7f1684461..a1eeaed01 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -5,6 +5,7 @@ using Altinn.App.Core.Features.Action; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; @@ -33,23 +34,19 @@ public class ActionsController : ControllerBase private readonly IValidationService _validationService; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; + private readonly IAppModel _appModel; /// /// Create new instance of the class /// - /// The authorization service - /// The instance client - /// The user action service - /// Service for performing validations of user data - /// Client for accessing data in storage - /// Service for getting application metadata public ActionsController( IAuthorizationService authorization, IInstanceClient instanceClient, UserActionService userActionService, IValidationService validationService, IDataClient dataClient, - IAppMetadata appMetadata + IAppMetadata appMetadata, + IAppModel appModel ) { _authorization = authorization; @@ -58,6 +55,7 @@ IAppMetadata appMetadata _validationService = validationService; _dataClient = dataClient; _appMetadata = appMetadata; + _appModel = appModel; } /// @@ -162,29 +160,40 @@ public async Task> Perform( ); } + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + Dictionary>>? validationIssues = null; + if (result.UpdatedDataModels is { Count: > 0 }) { - await SaveChangedModels(instance, result.UpdatedDataModels); + var changes = await SaveChangedModels(instance, dataAccessor, result.UpdatedDataModels); + + validationIssues = await GetValidations( + instance, + dataAccessor, + changes, + actionRequest.IgnoredValidators, + language + ); } - return new OkObjectResult( + return Ok( new UserActionResponse() { ClientActions = result.ClientActions, UpdatedDataModels = result.UpdatedDataModels, - UpdatedValidationIssues = await GetValidations( - instance, - result.UpdatedDataModels, - actionRequest.IgnoredValidators, - language - ), + UpdatedValidationIssues = validationIssues, RedirectUrl = result.RedirectUrl, } ); } - private async Task SaveChangedModels(Instance instance, Dictionary resultUpdatedDataModels) + private async Task> SaveChangedModels( + Instance instance, + CachedInstanceDataAccessor dataAccessor, + Dictionary resultUpdatedDataModels + ) { + var changes = new List(); var instanceIdentifier = new InstanceIdentifier(instance); foreach (var (elementId, newModel) in resultUpdatedDataModels) { @@ -192,11 +201,12 @@ private async Task SaveChangedModels(Instance instance, Dictionary d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); + var previousData = await dataAccessor.Get(dataElement); ObjectUtils.InitializeAltinnRowId(newModel); ObjectUtils.PrepareModelForXmlStorage(newModel); - var dataElement = instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); await _dataClient.UpdateData( newModel, instanceIdentifier.InstanceGuid, @@ -206,61 +216,65 @@ await _dataClient.UpdateData( instanceIdentifier.InstanceOwnerPartyId, Guid.Parse(dataElement.Id) ); + // update dataAccessor to use the changed data + dataAccessor.Set(dataElement, newModel); + // add change to list + changes.Add( + new DataElementChange + { + DataElement = dataElement, + PreviousValue = previousData, + CurrentValue = newModel, + } + ); } + return changes; } - private async Task>>?> GetValidations( + private async Task>>?> GetValidations( Instance instance, - Dictionary? resultUpdatedDataModels, + IInstanceDataAccessor dataAccessor, + List changes, List? ignoredValidators, string? language ) { - if (resultUpdatedDataModels is null || resultUpdatedDataModels.Count < 1) - { - return null; - } - - var instanceIdentifier = new InstanceIdentifier(instance); - var application = await _appMetadata.GetApplicationMetadata(); + var taskId = instance.Process.CurrentTask.ElementId; + var validationIssues = await _validationService.ValidateIncrementalFormData( + instance, + taskId, + changes, + dataAccessor, + ignoredValidators, + language + ); - var updatedValidationIssues = new Dictionary>>(); + // For historical reasons the validation issues from actions controller is separated per data element + // The easiest way was to keep this behaviour to improve compatibility with older frontend versions + return PartitionValidationIssuesByDataElement(validationIssues); + } - // TODO: Consider validating models in parallel - foreach (var (elementId, newModel) in resultUpdatedDataModels) + private Dictionary< + string, + Dictionary> + > PartitionValidationIssuesByDataElement(Dictionary> validationIssues) + { + var updatedValidationIssues = new Dictionary>>(); + foreach (var (validationSource, issuesFromSource) in validationIssues) { - if (newModel is null) - { - continue; - } - - var dataElement = instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); - var dataType = application.DataTypes.First(d => - d.Id.Equals(dataElement.DataType, StringComparison.OrdinalIgnoreCase) - ); - - // TODO: Consider rewriting so that we get the original data the IUserAction have requested instead of fetching it again - var oldData = await _dataClient.GetFormData( - instanceIdentifier.InstanceGuid, - newModel.GetType(), - instance.Org, - instance.AppId.Split('/')[1], - instanceIdentifier.InstanceOwnerPartyId, - Guid.Parse(dataElement.Id) - ); - - var validationIssues = await _validationService.ValidateFormData( - instance, - dataElement, - dataType, - newModel, - oldData, - ignoredValidators, - language - ); - if (validationIssues.Count > 0) + foreach (var issue in issuesFromSource) { - updatedValidationIssues.Add(elementId, validationIssues); + if (!updatedValidationIssues.TryGetValue(issue.DataElementId ?? "", out var elementIssues)) + { + elementIssues = new Dictionary>(); + updatedValidationIssues[issue.DataElementId ?? ""] = elementIssues; + } + if (!elementIssues.TryGetValue(validationSource, out var sourceIssues)) + { + sourceIssues = new List(); + elementIssues[validationSource] = sourceIssues; + } + sourceIssues.Add(issue); } } diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 958853a33..02a009c21 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -452,6 +452,53 @@ public async Task> PatchFormData( [FromBody] DataPatchRequest dataPatchRequest, [FromQuery] string? language = null ) + { + var request = new DataPatchRequestMultiple() + { + Patches = new() { [dataGuid] = dataPatchRequest.Patch }, + IgnoredValidators = dataPatchRequest.IgnoredValidators + }; + var response = await PatchFormDataMultiple(org, app, instanceOwnerPartyId, instanceGuid, request, language); + + if (response.Result is OkObjectResult { Value: DataPatchResponseMultiple newResponse }) + { + // Map the new response to the old response + return Ok( + new DataPatchResponse() + { + ValidationIssues = newResponse.ValidationIssues, + NewDataModel = newResponse.NewDataModels[dataGuid], + } + ); + } + + // Return the error object unchanged + return response.Result ?? throw new InvalidOperationException("Response is null"); + } + + /// + /// Updates an existing form data element with a patch of changes. + /// + /// unique identfier of the organisation responsible for the app + /// application identifier which is unique within an organisation + /// unique id of the party that is the owner of the instance + /// unique id to identify the instance + /// Container object for the and list of ignored validators + /// The language selected by the user. + /// A response object with the new full model and validation issues from all the groups that run + [Authorize(Policy = AuthzConstants.POLICY_INSTANCE_WRITE)] + [HttpPatch("")] + [ProducesResponseType(typeof(DataPatchResponseMultiple), 200)] + [ProducesResponseType(typeof(ProblemDetails), 409)] + [ProducesResponseType(typeof(ProblemDetails), 422)] + public async Task> PatchFormDataMultiple( + [FromRoute] string org, + [FromRoute] string app, + [FromRoute] int instanceOwnerPartyId, + [FromRoute] Guid instanceGuid, + [FromBody] DataPatchRequestMultiple dataPatchRequest, + [FromQuery] string? language = null + ) { try { @@ -464,44 +511,59 @@ public async Task> PatchFormData( ); } - var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); + CachedInstanceDataAccessor dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _appMetadata, + _appModel + ); - if (dataElement == null) + foreach (Guid dataGuid in dataPatchRequest.Patches.Keys) { - return NotFound("Did not find data element"); - } + var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); - var dataType = await GetDataType(dataElement); + if (dataElement == null) + { + return NotFound("Did not find data element"); + } - if (dataType?.AppLogic?.ClassRef is null) - { - _logger.LogError( - "Could not determine if {dataType} requires app logic for application {org}/{app}", - dataType, - org, - app - ); - return BadRequest($"Could not determine if data type {dataType?.Id} requires application logic."); + var dataType = await GetDataType(dataElement); + + if (dataType?.AppLogic?.ClassRef is null) + { + _logger.LogError( + "Could not determine if {dataType} requires app logic for application {org}/{app}", + dataType, + org, + app + ); + return BadRequest($"Could not determine if data type {dataType?.Id} requires application logic."); + } } - ServiceResult res = await _patchService.ApplyPatch( + ServiceResult res = await _patchService.ApplyPatches( instance, - dataType, - dataElement, - dataPatchRequest.Patch, + dataPatchRequest.Patches, language, dataPatchRequest.IgnoredValidators ); if (res.Success) { - await UpdateDataValuesOnInstance(instance, dataType.Id, res.Ok.NewDataModel); - await UpdatePresentationTextsOnInstance(instance, dataType.Id, res.Ok.NewDataModel); + foreach (var dataGuid in dataPatchRequest.Patches.Keys) + { + await UpdateDataValuesOnInstance(instance, dataGuid.ToString(), res.Ok.NewDataModels[dataGuid]); + await UpdatePresentationTextsOnInstance( + instance, + dataGuid.ToString(), + res.Ok.NewDataModels[dataGuid] + ); + } return Ok( - new DataPatchResponse + new DataPatchResponseMultiple() { - NewDataModel = res.Ok.NewDataModel, + NewDataModels = res.Ok.NewDataModels, ValidationIssues = res.Ok.ValidationIssues } ); @@ -513,7 +575,7 @@ public async Task> PatchFormData( { return HandlePlatformHttpException( e, - $"Unable to update data element {dataGuid} for instance {instanceOwnerPartyId}/{instanceGuid}" + $"Unable to update data element {string.Join(", ", dataPatchRequest.Patches.Keys)} for instance {instanceOwnerPartyId}/{instanceGuid}" ); } } diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 1881becd2..f8b4b537a 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -4,6 +4,9 @@ using Altinn.App.Api.Models; using Altinn.App.Core.Constants; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; @@ -38,6 +41,9 @@ public class ProcessController : ControllerBase private readonly IAuthorizationService _authorization; private readonly IProcessEngine _processEngine; private readonly IProcessReader _processReader; + private readonly IDataClient _dataClient; + private readonly IAppMetadata _appMetadata; + private readonly IAppModel _appModel; /// /// Initializes a new instance of the @@ -49,7 +55,10 @@ public ProcessController( IValidationService validationService, IAuthorizationService authorization, IProcessReader processReader, - IProcessEngine processEngine + IProcessEngine processEngine, + IDataClient dataClient, + IAppMetadata appMetadata, + IAppModel appModel ) { _logger = logger; @@ -59,6 +68,9 @@ IProcessEngine processEngine _authorization = authorization; _processReader = processReader; _processEngine = processEngine; + _dataClient = dataClient; + _appMetadata = appMetadata; + _appModel = appModel; } /// @@ -237,7 +249,13 @@ [FromRoute] Guid instanceGuid string? language ) { - var validationIssues = await _validationService.ValidateInstanceAtTask(instance, currentTaskId, language); + var dataAcceesor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var validationIssues = await _validationService.ValidateInstanceAtTask( + instance, + currentTaskId, + dataAcceesor, + language + ); var success = validationIssues.TrueForAll(v => v.Severity != ValidationIssueSeverity.Error); if (!success) diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index 260432607..e8f231ff5 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -1,5 +1,7 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models.Validation; @@ -17,6 +19,8 @@ namespace Altinn.App.Api.Controllers; public class ValidateController : ControllerBase { private readonly IInstanceClient _instanceClient; + private readonly IDataClient _dataClient; + private readonly IAppModel _appModel; private readonly IAppMetadata _appMetadata; private readonly IValidationService _validationService; @@ -26,12 +30,16 @@ public class ValidateController : ControllerBase public ValidateController( IInstanceClient instanceClient, IValidationService validationService, - IAppMetadata appMetadata + IAppMetadata appMetadata, + IDataClient dataClient, + IAppModel appModel ) { _instanceClient = instanceClient; _validationService = validationService; _appMetadata = appMetadata; + _dataClient = dataClient; + _appModel = appModel; } /// @@ -45,6 +53,7 @@ IAppMetadata appMetadata /// The currently used language by the user (or null if not available) [HttpGet] [Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/validate")] + [ProducesResponseType(typeof(ValidationIssueWithSource), 200)] public async Task ValidateInstance( [FromRoute] string org, [FromRoute] string app, @@ -67,9 +76,11 @@ public async Task ValidateInstance( try { - List messages = await _validationService.ValidateInstanceAtTask( + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + List messages = await _validationService.ValidateInstanceAtTask( instance, taskId, + dataAccessor, language ); return Ok(messages); @@ -95,6 +106,9 @@ public async Task ValidateInstance( /// Unique id identifying specific data element /// The currently used language by the user (or null if not available) [HttpGet] + [Obsolete( + "There is no longer any concept of validating a single data element. Use the /validate endpoint instead." + )] [Route("{org}/{app}/instances/{instanceOwnerId:int}/{instanceId:guid}/data/{dataGuid:guid}/validate")] public async Task ValidateData( [FromRoute] string org, @@ -116,7 +130,7 @@ public async Task ValidateData( throw new ValidationException("Unable to validate instance without a started process."); } - List messages = new List(); + List messages = new List(); DataElement? element = instance.Data.FirstOrDefault(d => d.Id == dataGuid.ToString()); @@ -134,22 +148,29 @@ public async Task ValidateData( throw new ValidationException("Unknown element type."); } - messages.AddRange(await _validationService.ValidateDataElement(instance, element, dataType, language)); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + + // TODO: Consider filtering so that only relevant issues are reported. + messages.AddRange( + await _validationService.ValidateInstanceAtTask(instance, dataType.TaskId, dataAccessor, language) + ); string taskId = instance.Process.CurrentTask.ElementId; // Should this be a BadRequest instead? if (!dataType.TaskId.Equals(taskId, StringComparison.OrdinalIgnoreCase)) { - ValidationIssue message = new ValidationIssue - { - Code = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, - Severity = ValidationIssueSeverity.Warning, - DataElementId = element.Id, - Description = $"Data element for task {dataType.TaskId} validated while currentTask is {taskId}", - CustomTextKey = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, - CustomTextParams = new List() { dataType.TaskId, taskId }, - }; + ValidationIssueWithSource message = + new() + { + Code = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, + Severity = ValidationIssueSeverity.Warning, + DataElementId = element.Id, + Description = $"Data element for task {dataType.TaskId} validated while currentTask is {taskId}", + CustomTextKey = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, + CustomTextParams = new List() { dataType.TaskId, taskId }, + Source = GetType().FullName ?? String.Empty + }; messages.Add(message); } diff --git a/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs b/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs new file mode 100644 index 000000000..75a6061d5 --- /dev/null +++ b/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using Altinn.App.Api.Controllers; +using Altinn.Platform.Storage.Interface.Models; +using Json.Patch; + +namespace Altinn.App.Api.Models; + +/// +/// Represents the request to patch data on the in the +/// version that supports multiple data models in the same request. +/// +public class DataPatchRequestMultiple +{ + /// + /// The Patch operation to perform in a dictionary keyed on the . + /// + [JsonPropertyName("patches")] + public required Dictionary Patches { get; init; } + + /// + /// List of validators to ignore during the patch operation. + /// Issues from these validators will not be run during the save operation, but the validator will run on process/next + /// + [JsonPropertyName("ignoredValidators")] + public required List? IgnoredValidators { get; init; } +} diff --git a/src/Altinn.App.Api/Models/DataPatchResponse.cs b/src/Altinn.App.Api/Models/DataPatchResponse.cs index 97fe3e0cf..0d453caed 100644 --- a/src/Altinn.App.Api/Models/DataPatchResponse.cs +++ b/src/Altinn.App.Api/Models/DataPatchResponse.cs @@ -11,7 +11,7 @@ public class DataPatchResponse /// /// The validation issues that were found during the patch operation. /// - public required Dictionary> ValidationIssues { get; init; } + public required Dictionary> ValidationIssues { get; init; } /// /// The current data model after the patch operation. diff --git a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs new file mode 100644 index 000000000..dd64c5352 --- /dev/null +++ b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs @@ -0,0 +1,20 @@ +using Altinn.App.Api.Controllers; +using Altinn.App.Core.Models.Validation; + +namespace Altinn.App.Api.Models; + +/// +/// Represents the response from a data patch operation on the . +/// +public class DataPatchResponseMultiple +{ + /// + /// The validation issues that were found during the patch operation. + /// + public required Dictionary> ValidationIssues { get; init; } + + /// + /// The current data in all data models updated by the patch operation. + /// + public required Dictionary NewDataModels { get; init; } +} diff --git a/src/Altinn.App.Api/Models/UserActionResponse.cs b/src/Altinn.App.Api/Models/UserActionResponse.cs index 2eb23150c..a03224fd3 100644 --- a/src/Altinn.App.Api/Models/UserActionResponse.cs +++ b/src/Altinn.App.Api/Models/UserActionResponse.cs @@ -20,7 +20,10 @@ public class UserActionResponse /// Validators that are not listed in the dictionary are assumed to have not been executed /// [JsonPropertyName("updatedValidationIssues")] - public Dictionary>>? UpdatedValidationIssues { get; set; } + public Dictionary< + string, + Dictionary> + >? UpdatedValidationIssues { get; set; } /// /// Actions the client should perform after action has been performed backend diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 94f526c9e..4d0e3dd2e 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -205,7 +205,6 @@ IWebHostEnvironment env private static void AddValidationServices(IServiceCollection services, IConfiguration configuration) { - CachedFormDataAccessor.Register(services); services.AddTransient(); services.AddScoped(); if (configuration.GetSection("AppSettings").Get()?.RequiredValidation == true) @@ -218,9 +217,7 @@ private static void AddValidationServices(IServiceCollection services, IConfigur services.AddTransient(); } services.AddTransient(); - services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); } diff --git a/src/Altinn.App.Core/Features/ITaskValidator.cs b/src/Altinn.App.Core/Features/ITaskValidator.cs index 667e1164d..1cf2c6abf 100644 --- a/src/Altinn.App.Core/Features/ITaskValidator.cs +++ b/src/Altinn.App.Core/Features/ITaskValidator.cs @@ -10,26 +10,18 @@ namespace Altinn.App.Core.Features; public interface ITaskValidator { /// - /// The task id this validator is for. Typically either hard coded by implementation or - /// or set by constructor using a and a keyed service. + /// The task id this validator is for, or "*" if relevant for all tasks. /// - /// - /// - /// string TaskId { get; init; } - /// // constructor - /// public MyTaskValidator([ServiceKey] string taskId) - /// { - /// TaskId = taskId; - /// } - /// - /// string TaskId { get; } /// - /// Returns the group id of the validator. - /// The default is based on the FullName and TaskId fields, and should not need customization + /// Returns the name to be used in the "Source" of property in all + /// 's created by the validator. /// - string ValidationSource => $"{this.GetType().FullName}-{TaskId}"; + /// + /// The default is based on the FullName and TaskId fields, and should not need customization + /// + string ValidationSource => $"{GetType().FullName}-{TaskId}"; /// /// Actual validation logic for the task diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs new file mode 100644 index 000000000..17daffdf3 --- /dev/null +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -0,0 +1,86 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features; + +/// +/// Main interface for validation of instances +/// +public interface IValidator +{ + /// + /// The task id for the task that the validator is associated with or "*" if the validator should run for all tasks. + /// + public string TaskId { get; } + + /// + /// Unique string that identifies the source of the validation issues from this validator + /// Used for incremental validation. Default implementation should typically work. + /// + public string ValidationSource => $"{GetType().FullName}-{TaskId}"; + + /// + /// + /// + /// The instance to validate + /// The current task. + /// Language for messages, if the messages are too dynamic for the translation system + /// Use this to access data from other data elements + /// + public Task> Validate( + Instance instance, + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor + ); + + /// + /// For patch requests we typically don't run all validators, because some validators will predictably produce the same issues as previously. + /// This method is used to determine if the validator has relevant changes, or if the cached issues list can be used. + /// + /// The instance to validate + /// The current task ID + /// List of changed data elements with current and previous value + /// Use this to access data from other data elements + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ); +} + +/// +/// Represents a change in a data element with current and previous deserialized data +/// +public class DataElementChange +{ + /// + /// The data element the change is related to + /// + public required DataElement DataElement { get; init; } + + /// + /// The state of the data element before the change + /// + public required object PreviousValue { get; init; } + + /// + /// The state of the data element after the change + /// + public required object CurrentValue { get; init; } +} + +/// +/// Service for accessing data from other data elements in the +/// +public interface IInstanceDataAccessor +{ + /// + /// Get the actual data represented in the data element. + /// + /// The data element to retrieve. Must be from the instance that is currently active + /// The deserialized data model for this data element or a stream for binary elements + Task Get(DataElement dataElement); +} diff --git a/src/Altinn.App.Core/Features/Telemetry.Data.cs b/src/Altinn.App.Core/Features/Telemetry.Data.cs index a049d74e2..b17b57708 100644 --- a/src/Altinn.App.Core/Features/Telemetry.Data.cs +++ b/src/Altinn.App.Core/Features/Telemetry.Data.cs @@ -32,6 +32,12 @@ internal void DataPatched(PatchResult result) => return activity; } + internal Activity? StartDataProcessWriteActivity(IDataProcessor dataProcessor) + { + var activity = ActivitySource.StartActivity($"{Prefix}.ProcessWrite.{dataProcessor.GetType().Name}"); + return activity; + } + internal static class Data { internal const string Prefix = "Data"; diff --git a/src/Altinn.App.Core/Features/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry.Validation.cs index 9ef4c4763..b4ac4eb84 100644 --- a/src/Altinn.App.Core/Features/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry.Validation.cs @@ -18,52 +18,27 @@ private void InitValidation(InitContext context) { } return activity; } - internal Activity? StartRunTaskValidatorActivity(ITaskValidator validator) + internal Activity? StartValidateIncrementalActivity( + Instance instance, + string taskId, + List changes + ) { - var activity = ActivitySource.StartActivity($"{Prefix}.RunTaskValidator"); - - activity?.SetTag(InternalLabels.ValidatorType, validator.GetType().Name); - activity?.SetTag(InternalLabels.ValidatorSource, validator.ValidationSource); - - return activity; - } - - internal Activity? StartValidateDataElementActivity(Instance instance, DataElement dataElement) - { - var activity = ActivitySource.StartActivity($"{Prefix}.ValidateDataElement"); - activity?.SetInstanceId(instance); - activity?.SetDataElementId(dataElement); - return activity; - } - - internal Activity? StartRunDataElementValidatorActivity(IDataElementValidator validator) - { - var activity = ActivitySource.StartActivity($"{Prefix}.RunDataElementValidator"); - - activity?.SetTag(InternalLabels.ValidatorType, validator.GetType().Name); - activity?.SetTag(InternalLabels.ValidatorSource, validator.ValidationSource); - - return activity; - } - - internal Activity? StartValidateFormDataActivity(Instance instance, DataElement dataElement) - { - var activity = ActivitySource.StartActivity($"{Prefix}.ValidateFormData"); + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + ArgumentNullException.ThrowIfNull(changes); + var activity = ActivitySource.StartActivity($"{Prefix}.ValidateIncremental"); + activity?.SetTaskId(taskId); activity?.SetInstanceId(instance); - activity?.SetDataElementId(dataElement); + // TODO: record the guid for the changed elements in a sensible list return activity; } - internal Activity? StartRunFormDataValidatorActivity(IFormDataValidator validator) - { - var activity = ActivitySource.StartActivity($"{Prefix}.RunFormDataValidator"); - - activity?.SetTag(InternalLabels.ValidatorType, validator.GetType().Name); - activity?.SetTag(InternalLabels.ValidatorSource, validator.ValidationSource); - - return activity; - } + internal Activity? StartRunValidatorActivity(IValidator validator) => + ActivitySource + .StartActivity($"{Prefix}.RunValidator") + ?.SetTag(InternalLabels.ValidatorType, validator.GetType().Name) + .SetTag(InternalLabels.ValidatorSource, validator.ValidationSource); internal static class Validation { diff --git a/src/Altinn.App.Core/Features/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry.cs index d389fb79c..587009854 100644 --- a/src/Altinn.App.Core/Features/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry.cs @@ -180,6 +180,7 @@ internal static class InternalLabels internal const string AuthorizerTaskId = "authorization.authorizer.task.id"; internal const string ValidatorType = "validator.type"; internal const string ValidatorSource = "validator.source"; + internal const string ValidatorRelevantChanges = "validator.relevant_changes"; } private void InitMetricCounter(InitContext context, string name, Action> init) diff --git a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs index 210afb25b..277a18e1b 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs @@ -42,7 +42,7 @@ IOptions generalSettings /// /// This validator has the code "DataAnnotations" and this is known by the frontend, who may request this validator to not run for incremental validation. /// - public string ValidationSource => "DataAnnotations"; + public string ValidationSource => ValidationIssueSources.DataAnnotations; /// /// We don't know which fields are relevant for data annotation validation, so we always run it. @@ -83,8 +83,7 @@ public Task> ValidateFormData( instance, dataElement, _generalSettings, - data.GetType(), - ValidationIssueSources.ModelState + data.GetType() ) ); } diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs index 999ebb644..34c7f34a2 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs @@ -53,7 +53,8 @@ public Task> ValidateDataElement( DataElementId = dataElement.Id, Code = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.ContentTypeNotAllowed, + Description = + $"ContentType {contentTypeWithoutEncoding} not allowed for {string.Join(",", dataType.AllowedContentTypes)}", Field = dataType.Id } ); diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 8b8fca981..1f72203c9 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -42,7 +42,7 @@ ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer /// /// This validator has the code "Expression" and this is known by the frontend, who may request this validator to not run for incremental validation. /// - public string ValidationSource => "Expression"; + public string ValidationSource => ValidationIssueSources.Expression; /// /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway @@ -121,7 +121,6 @@ public async Task> ValidateFormData( Severity = validation.Severity ?? ValidationIssueSeverity.Error, CustomTextKey = validation.Message, Code = validation.Message, - Source = ValidationIssueSources.Expression, }; validationIssues.Add(validationIssue); diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index 5533a391f..34617f3cd 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features.Validation.Helpers; +using Altinn.App.Core.Internal.App; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -12,9 +13,10 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// This validator is used to run the legacy IInstanceValidator.ValidateData method /// -public class LegacyIInstanceValidatorFormDataValidator : IFormDataValidator +public class LegacyIInstanceValidatorFormDataValidator : IValidator { - private readonly IInstanceValidator? _instanceValidator; + private readonly IInstanceValidator _instanceValidator; + private readonly IAppMetadata _appMetadata; private readonly GeneralSettings _generalSettings; /// @@ -22,17 +24,19 @@ public class LegacyIInstanceValidatorFormDataValidator : IFormDataValidator /// public LegacyIInstanceValidatorFormDataValidator( IOptions generalSettings, - IInstanceValidator? instanceValidator = null + IInstanceValidator instanceValidator, + IAppMetadata appMetadata ) { _instanceValidator = instanceValidator; + _appMetadata = appMetadata; _generalSettings = generalSettings.Value; } /// - /// The legacy validator should run for all data types + /// The legacy validator should run for all tasks, because there is no way to specify task for the legacy validator /// - public string DataType => _instanceValidator is null ? "" : "*"; + public string TaskId => "*"; /// > public string ValidationSource @@ -45,33 +49,46 @@ public string ValidationSource } } - /// - /// Always run for incremental validation (if it exists) - /// - public bool HasRelevantChanges(object current, object previous) => _instanceValidator is not null; - /// - public async Task> ValidateFormData( + public async Task> Validate( Instance instance, - DataElement dataElement, - object data, - string? language + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor ) { - if (_instanceValidator is null) + var issues = new List(); + var appMetadata = await _appMetadata.GetApplicationMetadata(); + var dataTypes = appMetadata.DataTypes.Where(d => d.TaskId == taskId).Select(d => d.Id).ToList(); + foreach (var dataElement in instance.Data.Where(d => dataTypes.Contains(d.DataType))) { - return new List(); + var data = await instanceDataAccessor.Get(dataElement); + var modelState = new ModelStateDictionary(); + await _instanceValidator.ValidateData(data, modelState); + issues.AddRange( + ModelStateHelpers.ModelStateToIssueList( + modelState, + instance, + dataElement, + _generalSettings, + data.GetType() + ) + ); } - var modelState = new ModelStateDictionary(); - await _instanceValidator.ValidateData(data, modelState); - return ModelStateHelpers.ModelStateToIssueList( - modelState, - instance, - dataElement, - _generalSettings, - data.GetType(), - ValidationIssueSources.Custom - ); + return issues; + } + + /// + /// Always run for incremental validation, because the legacy validator don't have a way to know when changes are relevant + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) + { + return Task.FromResult(true); } } diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs index e1c48343e..5920289ed 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs @@ -12,9 +12,9 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Ensures that the old extension hook is still supported. /// -public class LegacyIInstanceValidatorTaskValidator : ITaskValidator +public class LegacyIInstanceValidatorTaskValidator : IValidator { - private readonly IInstanceValidator? _instanceValidator; + private readonly IInstanceValidator _instanceValidator; private readonly GeneralSettings _generalSettings; /// @@ -22,7 +22,7 @@ public class LegacyIInstanceValidatorTaskValidator : ITaskValidator /// public LegacyIInstanceValidatorTaskValidator( IOptions generalSettings, - IInstanceValidator? instanceValidator = null + IInstanceValidator instanceValidator ) { _instanceValidator = instanceValidator; @@ -39,22 +39,35 @@ public string ValidationSource { get { - var type = _instanceValidator?.GetType() ?? GetType(); + var type = _instanceValidator.GetType(); Debug.Assert(type.FullName is not null, "FullName does not return null on class/struct types"); return type.FullName; } } /// - public async Task> ValidateTask(Instance instance, string taskId, string? language) + public async Task> Validate( + Instance instance, + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor + ) { - if (_instanceValidator is null) - { - return new List(); - } - var modelState = new ModelStateDictionary(); await _instanceValidator.ValidateTask(instance, taskId, modelState); return ModelStateHelpers.MapModelStateToIssueList(modelState, instance, _generalSettings); } + + /// + /// Don't run the legacy Instance validator for incremental validation (it was not running before) + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) + { + return Task.FromResult(false); + } } diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index 22c2db084..ff50289d6 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -27,7 +27,7 @@ public RequiredLayoutValidator(ILayoutEvaluatorStateInitializer layoutEvaluatorS /// /// This validator has the code "Required" and this is known by the frontend, who may request this validator to not run for incremental validation. /// - public string ValidationSource => "Required"; + public string ValidationSource => ValidationIssueSources.Required; /// /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway diff --git a/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs index a22871460..c30d67f5e 100644 --- a/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs +++ b/src/Altinn.App.Core/Features/Validation/Helpers/ModelStateHelpers.cs @@ -21,15 +21,13 @@ public static class ModelStateHelpers /// Data element for populating issue.DataElementId /// General settings to get *Fixed* prefixes /// Type of the object to map ModelStateDictionary key to the json path field (might be different) - /// issue.Source /// A list of the issues as our standard ValidationIssue public static List ModelStateToIssueList( ModelStateDictionary modelState, Instance instance, DataElement dataElement, GeneralSettings generalSettings, - Type objectType, - string source + Type objectType ) { var validationIssues = new List(); @@ -47,7 +45,6 @@ string source new ValidationIssue { DataElementId = dataElement.Id, - Source = source, Code = severityAndMessage.Message, Field = ModelKeyToField(modelKey, objectType), Severity = severityAndMessage.Severity, diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs new file mode 100644 index 000000000..090730823 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs @@ -0,0 +1,79 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation.Wrappers; + +/// +/// Wrap the old interface to the new interface. +/// +internal class DataElementValidatorWrapper : IValidator +{ + private readonly IDataElementValidator _dataElementValidator; + private readonly string _taskId; + private readonly List _dataTypes; + + public DataElementValidatorWrapper( + IDataElementValidator dataElementValidator, + string taskId, + List dataTypes + ) + { + _dataElementValidator = dataElementValidator; + _taskId = taskId; + _dataTypes = dataTypes; + } + + /// + public string TaskId => _taskId; + + /// + public string ValidationSource => _dataElementValidator.ValidationSource; + + /// + /// Run all legacy instances for the given . + /// + public async Task> Validate( + Instance instance, + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor + ) + { + var issues = new List(); + var validateAllElements = _dataElementValidator.DataType == "*"; + foreach (var dataElement in instance.Data) + { + if (validateAllElements || _dataElementValidator.DataType == dataElement.DataType) + { + var dataType = _dataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType is null) + { + throw new InvalidOperationException( + $"DataType {dataElement.DataType} not found in dataTypes from applicationmetadata" + ); + } + var dataElementValidationResult = await _dataElementValidator.ValidateDataElement( + instance, + dataElement, + dataType, + language + ); + issues.AddRange(dataElementValidationResult); + } + } + + return issues; + } + + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) + { + // DataElementValidator did not previously implement incremental validation, so we always return false + return Task.FromResult(false); + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs new file mode 100644 index 000000000..2e85bbd25 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -0,0 +1,88 @@ +namespace Altinn.App.Core.Features.Validation.Wrappers; + +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +/// +/// Wrap the old interface to the new interface. +/// +internal class FormDataValidatorWrapper : IValidator +{ + private readonly IFormDataValidator _formDataValidator; + private readonly string _taskId; + private readonly List _dataTypes; + + public FormDataValidatorWrapper(IFormDataValidator formDataValidator, string taskId, List dataTypes) + { + _formDataValidator = formDataValidator; + _taskId = taskId; + _dataTypes = dataTypes; + } + + /// + public string TaskId => _taskId; + + /// + public string ValidationSource => _formDataValidator.ValidationSource; + + /// + /// Run all legacy instances for the given . + /// + public async Task> Validate( + Instance instance, + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor + ) + { + var issues = new List(); + var validateAllElements = _formDataValidator.DataType == "*"; + foreach (var dataElement in instance.Data) + { + if (!validateAllElements && _formDataValidator.DataType != dataElement.DataType) + { + continue; + } + + var data = await instanceDataAccessor.Get(dataElement); + var dataElementValidationResult = await _formDataValidator.ValidateFormData( + instance, + dataElement, + data, + language + ); + issues.AddRange(dataElementValidationResult); + } + + return issues; + } + + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) + { + try + { + foreach (var change in changes) + { + if ( + (_formDataValidator.DataType == "*" || _formDataValidator.DataType == change.DataElement.DataType) + && _formDataValidator.HasRelevantChanges(change.CurrentValue, change.PreviousValue) + ) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + catch (Exception e) + { + return Task.FromException(e); + } + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs new file mode 100644 index 000000000..3b0221443 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs @@ -0,0 +1,49 @@ +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features.Validation.Wrappers; + +/// +/// Wrap the old interface to the new interface. +/// +internal class TaskValidatorWrapper : IValidator +{ + private readonly ITaskValidator _taskValidator; + + /// + /// Constructor that wraps an + /// + public TaskValidatorWrapper(ITaskValidator taskValidator) + { + _taskValidator = taskValidator; + } + + /// + public string TaskId => _taskValidator.TaskId; + + /// + public string ValidationSource => _taskValidator.ValidationSource; + + /// + public Task> Validate( + Instance instance, + string taskId, + string? language, + IInstanceDataAccessor instanceDataAccessor + ) + { + return _taskValidator.ValidateTask(instance, taskId, language); + } + + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) + { + // TaskValidator did not previously implement incremental validation, so we always return false + return Task.FromResult(false); + } +} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/TextClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/TextClient.cs index d35eba899..31b6e06fd 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/TextClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/TextClient.cs @@ -14,7 +14,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage; /// -/// A client forretrieving text resources from Altinn Platform. +/// A client for retrieving text resources from Altinn Platform. /// [Obsolete("Use IAppResources.GetTexts() instead")] public class TextClient : IText diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index 61d472aa4..7680f6bba 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -1,51 +1,47 @@ using System.Collections.Concurrent; using System.Globalization; +using Altinn.App.Core.Features; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace Altinn.App.Core.Internal.Data; /// /// Class that caches form data to avoid multiple calls to the data service for a single validation /// -/// Must be registered as a scoped service in DI container +/// Do not add this to the DI container, as it should only be created explicitly because of data leak potential. /// -internal sealed class CachedFormDataAccessor : ICachedFormDataAccessor +internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor { + private readonly string _org; + private readonly string _app; + private readonly Guid _instanceGuid; + private readonly int _instanceOwnerPartyId; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; private readonly IAppModel _appModel; - private readonly IHttpContextAccessor _contextAccessor; - private readonly string _requestIdentifier; private readonly LazyCache _cache = new(); - public CachedFormDataAccessor( + public CachedInstanceDataAccessor( + Instance instance, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel, - IHttpContextAccessor contextAccessor + IAppModel appModel ) { + _org = instance.Org; + _app = instance.AppId.Split("/")[1]; + _instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + _instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); _dataClient = dataClient; _appMetadata = appMetadata; _appModel = appModel; - _contextAccessor = contextAccessor; - ArgumentNullException.ThrowIfNull(_contextAccessor.HttpContext); - _requestIdentifier = _contextAccessor.HttpContext.TraceIdentifier; } /// - public async Task Get(Instance instance, DataElement dataElement) + public async Task Get(DataElement dataElement) { - // Be completly sure that the cache is only used in a single http request - if (_requestIdentifier != _contextAccessor.HttpContext?.TraceIdentifier) - { - throw new Exception("Cache can only be used in a single http request"); - } - return await _cache.GetOrCreate( dataElement.Id, async _ => @@ -59,15 +55,19 @@ public async Task Get(Instance instance, DataElement dataElement) if (dataType.AppLogic?.ClassRef != null) { - return await GetFormData(instance, dataElement, dataType); + return await GetFormData(dataElement, dataType); } - return await GetBinaryData(instance, dataElement); + return await GetBinaryData(dataElement); } ); } - /// + /// + /// Add data to the cache, so that it won't be fetched again + /// + /// + /// public void Set(DataElement dataElement, object data) { _cache.Set(dataElement.Id, data); @@ -86,64 +86,53 @@ private sealed class LazyCache public async Task GetOrCreate(TKey key, Func> valueFactory) { - return await _cache.GetOrAdd(key, innerKey => new Lazy>(() => valueFactory(innerKey))).Value; + Task task; + lock (_cache) + { + task = _cache.GetOrAdd(key, innerKey => new Lazy>(() => valueFactory(innerKey))).Value; + } + ; + return await task; } public void Set(TKey key, TValue data) { - if (!_cache.TryAdd(key, new Lazy>(Task.FromResult(data)))) + lock (_cache) { - var existing = _cache[key]; - if ( - existing.IsValueCreated - && existing.Value.IsCompletedSuccessfully - && data.Equals(existing.Value.Result) - ) - { - // We are trying to set the same value again, so we can just ignore this - return; - } - - throw new InvalidOperationException($"Key {key} already exists in cache"); + _cache.AddOrUpdate( + key, + _ => new Lazy>(Task.FromResult(data)), + (_, _) => new Lazy>(Task.FromResult(data)) + ); } } } - private async Task GetBinaryData(Instance instance, DataElement dataElement) + private async Task GetBinaryData(DataElement dataElement) { - var instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - var app = instance.AppId.Split("/")[1]; - var instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + ; var data = await _dataClient.GetBinaryData( - instance.Org, - app, - instanceOwnerPartyId, - instanceGuid, + _org, + _app, + _instanceOwnerPartyId, + _instanceGuid, Guid.Parse(dataElement.Id) ); return data; } - private async Task GetFormData(Instance instance, DataElement dataElement, DataType dataType) + private async Task GetFormData(DataElement dataElement, DataType dataType) { var modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); - var instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - var app = instance.AppId.Split("/")[1]; - var instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); var data = await _dataClient.GetFormData( - instanceGuid, + _instanceGuid, modelType, - instance.Org, - app, - instanceOwnerPartyId, + _org, + _app, + _instanceOwnerPartyId, Guid.Parse(dataElement.Id) ); return data; } - - internal static void Register(IServiceCollection services) - { - services.AddScoped(); - } } diff --git a/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs deleted file mode 100644 index c2a9d09d4..000000000 --- a/src/Altinn.App.Core/Internal/Data/ICachedFormDataAccessor.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Altinn.Platform.Storage.Interface.Models; - -namespace Altinn.App.Core.Internal.Data; - -/// -/// Use this in your validators, dataProcessors to get form data from the cache -/// -/// Note that this is a scoped service and can't be used in singleton or transient services -/// -public interface ICachedFormDataAccessor -{ - /// - /// Get the deserialized data for a given data element - /// - Task Get(Instance instance, DataElement dataElement); - - /// - /// In PATCH requests we need to use the new object for the uploaded data element, instead of fetching from - /// - void Set(DataElement dataElement, object data); -} diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index ab613598f..49ecb4e1d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -171,7 +171,6 @@ ComponentContext context Field = field.Field, Description = $"{field.Field} is required in component with id {context.Component.Id}", Code = "required", - Source = ValidationIssueSources.Required } ); } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index e4217b455..2ee87794d 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; @@ -16,7 +17,7 @@ public class LayoutEvaluatorStateInitializer : ILayoutEvaluatorStateInitializer // Dependency injection properties (set in ctor) private readonly IAppResources _appResources; private readonly FrontEndSettings _frontEndSettings; - private readonly ICachedFormDataAccessor _dataAccessor; + private readonly IInstanceDataAccessor _dataAccessor; /// /// Constructor with services from dependency injection @@ -24,7 +25,7 @@ public class LayoutEvaluatorStateInitializer : ILayoutEvaluatorStateInitializer public LayoutEvaluatorStateInitializer( IAppResources appResources, IOptions frontEndSettings, - ICachedFormDataAccessor dataAccessor + IInstanceDataAccessor dataAccessor ) { _appResources = appResources; @@ -80,9 +81,7 @@ public async Task Init( dataTasks.AddRange( instance .Data.Where(dataElement => dataElement.DataType == dataType) - .Select(async dataElement => - KeyValuePair.Create(dataElement, await _dataAccessor.Get(instance, dataElement)) - ) + .Select(async dataElement => KeyValuePair.Create(dataElement, await _dataAccessor.Get(dataElement))) ); } diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index f434d7698..bdcc8f34b 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -10,10 +10,10 @@ public class DataPatchResult /// /// The validation issues that were found during the patch operation. /// - public required Dictionary> ValidationIssues { get; init; } + public required Dictionary> ValidationIssues { get; init; } /// /// The current data model after the patch operation. /// - public required object NewDataModel { get; init; } + public required Dictionary NewDataModels { get; init; } } diff --git a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs index 6a0e74869..2e860b7c6 100644 --- a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs @@ -14,18 +14,14 @@ public interface IPatchService /// Applies a patch to a Form Data element /// /// - /// - /// - /// + /// /// /// /// - public Task> ApplyPatch( + Task> ApplyPatches( Instance instance, - DataType dataType, - DataElement dataElement, - JsonPatch jsonPatch, + Dictionary patches, string? language, - List? ignoredValidators = null + List? ignoredValidators ); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index ec15c56dd..1e01ed0bd 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -18,7 +18,7 @@ namespace Altinn.App.Core.Internal.Patch; /// /// Service for applying patches to form data elements /// -public class PatchService : IPatchService +internal class PatchService : IPatchService { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; @@ -33,12 +33,6 @@ public class PatchService : IPatchService /// /// Creates a new instance of the class /// - /// - /// - /// - /// - /// - /// public PatchService( IAppMetadata appMetadata, IDataClient dataClient, @@ -57,96 +51,121 @@ public PatchService( } /// - public async Task> ApplyPatch( + public async Task> ApplyPatches( Instance instance, - DataType dataType, - DataElement dataElement, - JsonPatch jsonPatch, + Dictionary patches, string? language, - List? ignoredValidators = null + List? ignoredValidators ) { using var activity = _telemetry?.StartDataPatchActivity(instance); - InstanceIdentifier instanceIdentifier = new InstanceIdentifier(instance); + InstanceIdentifier instanceIdentifier = new(instance); AppIdentifier appIdentifier = (await _appMetadata.GetApplicationMetadata()).AppIdentifier; - var modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); - var oldModel = await _dataClient.GetFormData( - instanceIdentifier.InstanceGuid, - modelType, - appIdentifier.Org, - appIdentifier.App, - instanceIdentifier.InstanceOwnerPartyId, - Guid.Parse(dataElement.Id) - ); - var oldModelNode = JsonSerializer.SerializeToNode(oldModel); - var patchResult = jsonPatch.Apply(oldModelNode); - var telemetryPatchResult = ( - patchResult.IsSuccess ? Telemetry.Data.PatchResult.Success : Telemetry.Data.PatchResult.Error - ); - activity?.SetTag(InternalLabels.Result, telemetryPatchResult.ToStringFast()); - _telemetry?.DataPatched(telemetryPatchResult); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var changes = new List(); - if (!patchResult.IsSuccess) + foreach (var (dataElementId, jsonPatch) in patches) { - bool testOperationFailed = patchResult.Error.Contains("is not equal to the indicated value."); - return new DataPatchError() + var dataElement = instance.Data.Find(d => d.Id == dataElementId.ToString()); + if (dataElement is null) { - Title = testOperationFailed ? "Precondition in patch failed" : "Patch Operation Failed", - Detail = patchResult.Error, - ErrorType = testOperationFailed - ? DataPatchErrorType.PatchTestFailed - : DataPatchErrorType.DeserializationFailed, - Extensions = new Dictionary() + return new DataPatchError() { - { "previousModel", oldModel }, - { "patchOperationIndex", patchResult.Operation }, - } - }; - } + Title = "Unknown data element to patch", + Detail = $"Data element with id {dataElementId} not found in instance", + }; + } - var result = DeserializeModel(oldModel.GetType(), patchResult.Result); - if (!result.Success) - { - return new DataPatchError() + var oldModel = await dataAccessor.Get(dataElement); + var oldModelNode = JsonSerializer.SerializeToNode(oldModel); + var patchResult = jsonPatch.Apply(oldModelNode); + + if (!patchResult.IsSuccess) { - Title = "Patch operation did not deserialize", - Detail = result.Error, - ErrorType = DataPatchErrorType.DeserializationFailed - }; - } - Guid dataElementId = Guid.Parse(dataElement.Id); - foreach (var dataProcessor in _dataProcessors) - { - await dataProcessor.ProcessDataWrite(instance, dataElementId, result.Ok, oldModel, language); - } + bool testOperationFailed = patchResult.Error.Contains("is not equal to the indicated value."); + return new DataPatchError() + { + Title = testOperationFailed ? "Precondition in patch failed" : "Patch Operation Failed", + Detail = patchResult.Error, + ErrorType = testOperationFailed + ? DataPatchErrorType.PatchTestFailed + : DataPatchErrorType.DeserializationFailed, + Extensions = new Dictionary() + { + { "previousModel", oldModel }, + { "patchOperationIndex", patchResult.Operation }, + } + }; + } + + var newModelResult = DeserializeModel(oldModel.GetType(), patchResult.Result); + if (!newModelResult.Success) + { + return new DataPatchError() + { + Title = "Patch operation did not deserialize", + Detail = newModelResult.Error, + ErrorType = DataPatchErrorType.DeserializationFailed + }; + } + var newModel = newModelResult.Ok; - ObjectUtils.InitializeAltinnRowId(result.Ok); - ObjectUtils.PrepareModelForXmlStorage(result.Ok); + foreach (var dataProcessor in _dataProcessors) + { + using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataProcessor); + try + { + // TODO: Create new dataProcessor interface that takes multiple models at the same time. + await dataProcessor.ProcessDataWrite(instance, dataElementId, newModel, oldModel, language); + } + catch (Exception e) + { + processWriteActivity?.Errored(e); + throw; + } + } + ObjectUtils.InitializeAltinnRowId(newModel); + ObjectUtils.PrepareModelForXmlStorage(newModel); + changes.Add( + new DataElementChange + { + DataElement = dataElement, + PreviousValue = oldModel, + CurrentValue = newModel, + } + ); + + // save form data to storage + await _dataClient.UpdateData( + newModel, + instanceIdentifier.InstanceGuid, + newModel.GetType(), + appIdentifier.Org, + appIdentifier.App, + instanceIdentifier.InstanceOwnerPartyId, + dataElementId + ); + + // Ensure that validation runs on the modified model. + dataAccessor.Set(dataElement, newModel); + } - var validationIssues = await _validationService.ValidateFormData( + var validationIssues = await _validationService.ValidateIncrementalFormData( instance, - dataElement, - dataType, - result.Ok, - oldModel, + instance.Process.CurrentTask.ElementId, + changes, + dataAccessor, ignoredValidators, language ); - // Save Formdata to database - await _dataClient.UpdateData( - result.Ok, - instanceIdentifier.InstanceGuid, - modelType, - appIdentifier.Org, - appIdentifier.App, - instanceIdentifier.InstanceOwnerPartyId, - dataElementId - ); - - return new DataPatchResult { NewDataModel = result.Ok, ValidationIssues = validationIssues }; + return new DataPatchResult + { + NewDataModels = changes.ToDictionary(c => Guid.Parse(c.DataElement.Id), c => c.CurrentValue), + ValidationIssues = validationIssues + }; } private static ServiceResult DeserializeModel(Type type, JsonNode? patchResult) diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index f63ee5c14..77cb50be8 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -20,52 +20,31 @@ public interface IValidationService /// /// The instance to validate /// instance.Process?.CurrentTask?.ElementId + /// Accessor for instance data to be validated /// The language to run validations in /// List of validation issues for this data element - Task> ValidateInstanceAtTask(Instance instance, string taskId, string? language); - - /// - /// Validate a single data element regardless of whether it has AppLogic (eg. datamodel) or not. - /// - /// - /// This method executes validations in the following interfaces - /// * for all data elements on the current task - /// * for all data elements with app logic on the current task - /// - /// This method does not run task validations - /// - /// The instance to validate - /// The data element to run validations for - /// The data type (from applicationmetadata) that the element is an instance of - /// The language to run validations in - /// List of validation issues for this data element - Task> ValidateDataElement( + Task> ValidateInstanceAtTask( Instance instance, - DataElement dataElement, - DataType dataType, + string taskId, + IInstanceDataAccessor dataAccessor, string? language ); /// - /// Validates a single data element. Used by frontend to continuously validate form data as it changes. + /// /// - /// - /// This method executes validations for - /// - /// The instance to validate - /// The data element to run validations for - /// The type of the data element - /// The data deserialized to the strongly typed object that represents the form data - /// The previous data so that validators can know if they need to run again with - /// List validators that should not be run (for incremental validation). Typically known validators that frontend knows how to replicate - /// The language to run validations in - /// A dictionary containing lists of validation issues grouped by and/or - Task>> ValidateFormData( + /// + /// + /// + /// List of changed with both previous and next + /// + /// + /// + public Task>> ValidateIncrementalFormData( Instance instance, - DataElement dataElement, - DataType dataType, - object data, - object? previousData, + string taskId, + List changes, + IInstanceDataAccessor dataAccessor, List? ignoredValidators, string? language ); diff --git a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs index 1a677b199..c469b7b12 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs @@ -1,4 +1,10 @@ +using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Features.Validation.Wrappers; +using Altinn.App.Core.Internal.App; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Options; namespace Altinn.App.Core.Internal.Validation; @@ -10,17 +16,7 @@ public interface IValidatorFactory /// /// Gets all task validators for a given task. /// - public IEnumerable GetTaskValidators(string taskId); - - /// - /// Gets all data element validators for a given data element. - /// - public IEnumerable GetDataElementValidators(string dataTypeId); - - /// - /// Gets all form data validators for a given data element. - /// - public IEnumerable GetFormDataValidators(string dataTypeId); + public IEnumerable GetValidators(string taskId); } /// @@ -29,38 +25,124 @@ public interface IValidatorFactory public class ValidatorFactory : IValidatorFactory { private readonly IEnumerable _taskValidators; + private readonly IOptions _generalSettings; private readonly IEnumerable _dataElementValidators; private readonly IEnumerable _formDataValidators; + private readonly IEnumerable _validators; +#pragma warning disable CS0618 // Type or member is obsolete + private readonly IEnumerable _instanceValidators; +#pragma warning restore CS0618 // Type or member is obsolete + private readonly IAppMetadata _appMetadata; /// /// Initializes a new instance of the class. /// public ValidatorFactory( IEnumerable taskValidators, + IOptions generalSettings, IEnumerable dataElementValidators, - IEnumerable formDataValidators + IEnumerable formDataValidators, + IEnumerable validators, +#pragma warning disable CS0618 // Type or member is obsolete + IEnumerable instanceValidators, +#pragma warning restore CS0618 // Type or member is obsolete + IAppMetadata appMetadata ) { _taskValidators = taskValidators; + _generalSettings = generalSettings; _dataElementValidators = dataElementValidators; _formDataValidators = formDataValidators; + _validators = validators; + _instanceValidators = instanceValidators; + _appMetadata = appMetadata; } - /// - public IEnumerable GetTaskValidators(string taskId) + private IEnumerable GetTaskValidators(string taskId) { return _taskValidators.Where(tv => tv.TaskId == "*" || tv.TaskId == taskId); } - /// - public IEnumerable GetDataElementValidators(string dataTypeId) + private IEnumerable GetDataElementValidators(string taskId, List dataTypes) + { + foreach (var dataElementValidator in _dataElementValidators) + { + if (dataElementValidator.DataType == "*") + { + yield return dataElementValidator; + } + else + { + var dataType = dataTypes.Find(d => d.Id == dataElementValidator.DataType); + if (dataType is null) + { + throw new InvalidOperationException( + $"DataType {dataElementValidator.DataType} from {dataElementValidator.ValidationSource} not found in dataTypes from applicationmetadata" + ); + } + if (dataType.TaskId == taskId) + { + yield return dataElementValidator; + } + } + } + } + + private IEnumerable GetFormDataValidators(string taskId, List dataTypes) { - return _dataElementValidators.Where(dev => dev.DataType == "*" || dev.DataType == dataTypeId); + foreach (var formDataValidator in _formDataValidators) + { + if (formDataValidator.DataType == "*") + { + yield return formDataValidator; + } + else + { + var dataType = dataTypes.Find(d => d.Id == formDataValidator.DataType); + if (dataType is null) + { + throw new InvalidOperationException( + $"DataType {formDataValidator.DataType} from {formDataValidator.ValidationSource} not found in dataTypes from applicationmetadata" + ); + } + if (dataType.TaskId == taskId) + { + yield return formDataValidator; + } + } + } } - /// - public IEnumerable GetFormDataValidators(string dataTypeId) + /// + /// Get all validators for a given task. Wrap , and + /// so that they behave as . + /// + public IEnumerable GetValidators(string taskId) { - return _formDataValidators.Where(fdv => fdv.DataType == "*" || fdv.DataType == dataTypeId); + var validators = new List(); + // add new style validators + validators.AddRange(_validators); + // add legacy task validators, data element validators and form data validators + validators.AddRange(GetTaskValidators(taskId).Select(tv => new TaskValidatorWrapper(tv))); + var dataTypes = _appMetadata.GetApplicationMetadata().Result.DataTypes; + + validators.AddRange( + GetDataElementValidators(taskId, dataTypes) + .Select(dev => new DataElementValidatorWrapper(dev, taskId, dataTypes)) + ); + validators.AddRange( + GetFormDataValidators(taskId, dataTypes).Select(fdv => new FormDataValidatorWrapper(fdv, taskId, dataTypes)) + ); + + // add legacy instance validators wrapped in IValidator wrappers + foreach (var instanceValidator in _instanceValidators) + { + validators.Add(new LegacyIInstanceValidatorTaskValidator(_generalSettings, instanceValidator)); + validators.Add( + new LegacyIInstanceValidatorFormDataValidator(_generalSettings, instanceValidator, _appMetadata) + ); + } + + return validators; } } diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 748c8f489..33601a4b1 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -17,251 +17,144 @@ public class ValidationService : IValidationService private readonly IAppMetadata _appMetadata; private readonly ILogger _logger; private readonly Telemetry? _telemetry; - private readonly ICachedFormDataAccessor _formDataCache; /// /// Constructor with DI services /// public ValidationService( IValidatorFactory validatorFactory, - IDataClient dataClient, - IAppModel appModel, IAppMetadata appMetadata, ILogger logger, - ICachedFormDataAccessor formDataCache, Telemetry? telemetry = null ) { _validatorFactory = validatorFactory; _appMetadata = appMetadata; _logger = logger; - _formDataCache = formDataCache; _telemetry = telemetry; } /// - public async Task> ValidateInstanceAtTask(Instance instance, string taskId, string? language) - { - ArgumentNullException.ThrowIfNull(instance); - ArgumentNullException.ThrowIfNull(taskId); - - using var activity = _telemetry?.StartValidateInstanceAtTaskActivity(instance, taskId); - - // Run task validations (but don't await yet) - Task[]> taskIssuesTask = RunTaskValidators(instance, taskId, language); - - // Get list of data elements for the task - var application = await _appMetadata.GetApplicationMetadata(); - var dataTypesForTask = application.DataTypes.Where(dt => dt.TaskId == taskId).ToList(); - var dataElementsToValidate = instance - .Data.Where(de => dataTypesForTask.Exists(dt => dt.Id == de.DataType)) - .ToArray(); - // Run ValidateDataElement for each data element (but don't await yet) - var dataIssuesTask = Task.WhenAll( - dataElementsToValidate.Select(dataElement => - ValidateDataElement( - instance, - dataElement, - dataTypesForTask.First(dt => dt.Id == dataElement.DataType), - language - ) - ) - ); - - var lists = await Task.WhenAll(taskIssuesTask, dataIssuesTask); - // Flatten the list of lists to a single list of issues - return lists.SelectMany(x => x.SelectMany(y => y)).ToList(); - } - - private Task[]> RunTaskValidators(Instance instance, string taskId, string? language) - { - var taskValidators = _validatorFactory.GetTaskValidators(taskId); - - return Task.WhenAll( - taskValidators.Select(async v => - { - using var activity = _telemetry?.StartRunTaskValidatorActivity(v); - try - { - _logger.LogDebug( - "Start running validator {ValidatorName} on task {TaskId} in instance {InstanceId}", - v.ValidationSource, - taskId, - instance.Id - ); - var issues = await v.ValidateTask(instance, taskId, language); - issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the source is set to the validator source - return issues; - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while running validator {ValidatorName} on task {TaskId} in instance {InstanceId}", - v.ValidationSource, - taskId, - instance.Id - ); - activity?.Errored(e); - throw; - } - }) - ); - } - - /// - public async Task> ValidateDataElement( + public async Task> ValidateInstanceAtTask( Instance instance, - DataElement dataElement, - DataType dataType, + string taskId, + IInstanceDataAccessor dataAccessor, string? language ) { ArgumentNullException.ThrowIfNull(instance); - ArgumentNullException.ThrowIfNull(dataElement); - ArgumentNullException.ThrowIfNull(dataElement.DataType); - - using var activity = _telemetry?.StartValidateDataElementActivity(instance, dataElement); + ArgumentNullException.ThrowIfNull(taskId); - // Get both keyed and non-keyed validators for the data type - Task[]> dataElementsIssuesTask = RunDataElementValidators( - instance, - dataElement, - dataType, - language - ); + using var activity = _telemetry?.StartValidateInstanceAtTaskActivity(instance, taskId); - // Run extra validation on form data elements with app logic - if (dataType.AppLogic?.ClassRef is not null) + // Run task validations (but don't await yet) + var validators = _validatorFactory.GetValidators(taskId); + var validationTasks = validators.Select(async v => { - var data = await _formDataCache.Get(instance, dataElement); - var formDataIssuesDictionary = await ValidateFormData( - instance, - dataElement, - dataType, - data, - previousData: null, - ignoredValidators: null, - language - ); - - return (await dataElementsIssuesTask) - .SelectMany(x => x) - .Concat(formDataIssuesDictionary.SelectMany(kv => kv.Value)) - .ToList(); - } - - return (await dataElementsIssuesTask).SelectMany(x => x).ToList(); - } - - private Task[]> RunDataElementValidators( - Instance instance, - DataElement dataElement, - DataType dataType, - string? language - ) - { - var validators = _validatorFactory.GetDataElementValidators(dataType.Id); - - var dataElementsIssuesTask = Task.WhenAll( - validators.Select(async v => + using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); + try { - using var activity = _telemetry?.StartRunDataElementValidatorActivity(v); - try - { - _logger.LogDebug( - "Start running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.ValidationSource, - dataElement.DataType, - dataElement.Id, - instance.Id - ); - var issues = await v.ValidateDataElement(instance, dataElement, dataType, language); - issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the source is set to the validator source - return issues; - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while running validator {validatorName} on {dataType} for data element {dataElementId} in instance {instanceId}", - v.ValidationSource, - dataElement.DataType, - dataElement.Id, - instance.Id - ); - activity?.Errored(e); - throw; - } - }) - ); + var issues = await v.Validate(instance, taskId, language, dataAccessor); + return KeyValuePair.Create( + v.ValidationSource, + issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) + ); + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while running validator {validatorName} for task {taskId} on instance {instanceId}", + v.ValidationSource, + taskId, + instance.Id + ); + validatorActivity?.Errored(e); + throw; + } + }); + var lists = await Task.WhenAll(validationTasks); - return dataElementsIssuesTask; + // Flatten the list of lists to a single list of issues + return lists.SelectMany(x => x.Value).ToList(); } /// - public async Task>> ValidateFormData( + public async Task>> ValidateIncrementalFormData( Instance instance, - DataElement dataElement, - DataType dataType, - object data, - object? previousData, + string taskId, + List changes, + IInstanceDataAccessor dataAccessor, List? ignoredValidators, string? language ) { ArgumentNullException.ThrowIfNull(instance); - ArgumentNullException.ThrowIfNull(dataElement); - ArgumentNullException.ThrowIfNull(dataElement.DataType); - ArgumentNullException.ThrowIfNull(data); - - using var activity = _telemetry?.StartValidateFormDataActivity(instance, dataElement); + ArgumentNullException.ThrowIfNull(taskId); + ArgumentNullException.ThrowIfNull(changes); - // Set data from request instead of fetching the old data. - _formDataCache.Set(dataElement, data); + using var activity = _telemetry?.StartValidateIncrementalActivity(instance, taskId, changes); - // Locate the relevant data validator services from normal and keyed services - var dataValidators = _validatorFactory - .GetFormDataValidators(dataType.Id) - .Where(dv => ignoredValidators?.Contains(dv.ValidationSource) != true) // Filter out ignored validators - .Where(dv => previousData is null || dv.HasRelevantChanges(data, previousData)) + var validators = _validatorFactory + .GetValidators(taskId) + .Where(v => !(ignoredValidators?.Contains(v.ValidationSource) ?? false)) .ToArray(); - var validationTasks = dataValidators.Select(async v => + ThrowIfDuplicateValidators(validators, taskId); + + // Run task validations (but don't await yet) + var validationTasks = validators.Select(async validator => { - using var activity = _telemetry?.StartRunFormDataValidatorActivity(v); + using var validatorActivity = _telemetry?.StartRunValidatorActivity(validator); try { - _logger.LogDebug( - "Start running validator {ValidatorName} on {DataType} for data element {DataElementId} in instance {InstanceId}", - v.ValidationSource, - dataElement.DataType, - dataElement.Id, - instance.Id - ); - var issues = await v.ValidateFormData(instance, dataElement, data, language); - issues.ForEach(i => i.Source = v.ValidationSource); // Ensure that the Source is set to the ValidatorSource - return issues; + var hasRelevantChanges = await validator.HasRelevantChanges(instance, taskId, changes, dataAccessor); + validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorRelevantChanges, hasRelevantChanges); + if (hasRelevantChanges) + { + var issues = await validator.Validate(instance, taskId, language, dataAccessor); + var issuesWithSource = issues + .Select(i => ValidationIssueWithSource.FromIssue(i, validator.ValidationSource)) + .ToList(); + return new KeyValuePair?>( + validator.ValidationSource, + issuesWithSource + ); + } + + return new KeyValuePair?>(); } catch (Exception e) { _logger.LogError( e, - "Error while running validator {ValidatorName} on {DataType} for data element {DataElementId} in instance {InstanceId}", - v.ValidationSource, - dataElement.DataType, - dataElement.Id, + "Error while running validator {validatorName} on task {taskId} in instance {instanceId}", + validator.GetType().Name, + taskId, instance.Id ); - activity?.Errored(e); + validatorActivity?.Errored(e); throw; } }); - var validationSources = dataValidators.Select(d => d.ValidationSource).ToList(); + var lists = await Task.WhenAll(validationTasks); - var issuesLists = await Task.WhenAll(validationTasks); + // ! Value is null if no relevant changes. Filter out these before return with ! because ofType don't filter nullables. + return lists.Where(k => k.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value!); + } - return validationSources.Zip(issuesLists).ToDictionary(kv => kv.First, kv => kv.Second); + private void ThrowIfDuplicateValidators(IValidator[] validators, string taskId) + { + var sourceNames = validators + .Select(v => v.ValidationSource) + .Distinct(StringComparer.InvariantCultureIgnoreCase); + if (sourceNames.Count() != validators.Length) + { + var sources = string.Join('\n', validators.Select(v => $"{v.ValidationSource} {v.GetType().FullName}")); + throw new InvalidOperationException( + $"Duplicate validators found for task {taskId}. Ensure that each validator has a unique ValidationSource.\n\n{sources}" + ); + } } } diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs index e89effe44..130d959d7 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs @@ -54,6 +54,7 @@ public class ValidationIssue /// [JsonProperty(PropertyName = "code")] [JsonPropertyName("code")] + // TODO: Make this required for v9 public string? Code { get; set; } /// @@ -61,20 +62,16 @@ public class ValidationIssue /// [JsonProperty(PropertyName = "description")] [JsonPropertyName("description")] + // TODO: Make this required for v9 public string? Description { get; set; } /// /// The short name of the class that crated the message (set automatically after return of list) /// - /// - /// Intentionally not marked as "required", because it is set in - /// [JsonProperty(PropertyName = "source")] [JsonPropertyName("source")] -#nullable disable - public string Source { get; set; } - -#nullable restore + [Obsolete("Source is set automatically by the validation service. Setting it explicitly will be an error in v9")] + public string? Source { get; set; } /// /// The custom text key to use for the localized text in the frontend. diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueSource.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueSource.cs index 8f56fb15d..567a0b53e 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueSource.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueSource.cs @@ -29,4 +29,9 @@ public static class ValidationIssueSources /// Expression validation /// public static readonly string Expression = nameof(Expression); + + /// + /// Validation based on data annotations (json / xml schema) + /// + public static readonly string DataAnnotations = nameof(DataAnnotations); } diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs new file mode 100644 index 000000000..28d2de80a --- /dev/null +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models.Validation; + +/// +/// Represents a detailed message from validation. +/// +public class ValidationIssueWithSource +{ + /// + /// Converter function to create a from a and adding a source. + /// + public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string source) + { + return new ValidationIssueWithSource + { + Severity = issue.Severity, + DataElementId = issue.DataElementId, + Field = issue.Field, + Code = issue.Code, + Description = issue.Description, + Source = source, + CustomTextKey = issue.CustomTextKey, + CustomTextParams = issue.CustomTextParams, + }; + } + + /// + /// The seriousness of the identified issue. + /// + /// + /// This property is serialized in json as a number + /// 1: Error (something needs to be fixed) + /// 2: Warning (does not prevent submission) + /// 3: Information (hint shown to the user) + /// 4: Fixed (obsolete, only used for v3 of frontend) + /// 5: Success (Inform the user that something was completed with success) + /// + [JsonPropertyName("severity")] + [JsonConverter(typeof(JsonNumberEnumConverter))] + public required ValidationIssueSeverity Severity { get; set; } + + /// + /// The unique id of the data element of a given instance with the identified issue. + /// + [JsonPropertyName("dataElementId")] + public string? DataElementId { get; set; } + + /// + /// A reference to a property the issue is about. + /// + [JsonPropertyName("field")] + public string? Field { get; set; } + + /// + /// A system readable identification of the type of issue. + /// Eg: + /// + [JsonPropertyName("code")] + public required string? Code { get; set; } + + /// + /// A human readable description of the issue. + /// + [JsonPropertyName("description")] + public required string? Description { get; set; } + + /// + /// The short name of the class that crated the message (set automatically after return of list) + /// + [JsonPropertyName("source")] + public required string Source { get; set; } + + /// + /// The custom text key to use for the localized text in the frontend. + /// + [JsonPropertyName("customTextKey")] + public string? CustomTextKey { get; set; } + + /// + /// might include some parameters (typically the field value, or some derived value) + /// that should be included in error message. + /// + /// + /// The localized text for the key might be "Date must be between {0} and {1}" + /// and the param will provide the dynamical range of allowable dates (eg teh reporting period) + /// + [JsonPropertyName("customTextParams")] + public List? CustomTextParams { get; set; } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index eddcdfc51..7b5d68306 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -52,7 +52,8 @@ public class DataControllerPatchTests : ApiTestBase, IClassFixture factory, ITestOutputHelper outputHelper) : base(factory, outputHelper) { - _formDataValidatorMock.Setup(v => v.DataType).Returns("Not a valid data type"); + _formDataValidatorMock.Setup(v => v.DataType).Returns("9edd53de-f46f-40a1-bb4d-3efb93dc113d"); + _formDataValidatorMock.Setup(v => v.ValidationSource).Returns("Not a valid validation source"); OverrideServicesForAllTests = (services) => { services.AddSingleton(_dataProcessorMock.Object); diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs index a9418fb59..870092dc5 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs @@ -43,7 +43,8 @@ public class ProcessControllerTests : ApiTestBase, IClassFixture factory, ITestOutputHelper outputHelper) : base(factory, outputHelper) { - _formDataValidatorMock.Setup(v => v.DataType).Returns("Not a valid data type"); + _formDataValidatorMock.Setup(v => v.DataType).Returns("9edd53de-f46f-40a1-bb4d-3efb93dc113d"); + _formDataValidatorMock.Setup(v => v.ValidationSource).Returns("Not a valid validation source"); OverrideServicesForAllTests = (services) => { services.AddSingleton(_dataProcessorMock.Object); diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index 69094a4b8..7dceeba8b 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -1,9 +1,13 @@ using System.Net; using Altinn.App.Api.Controllers; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; @@ -14,29 +18,41 @@ namespace Altinn.App.Api.Tests.Controllers; public class ValidateControllerTests { + private const string Org = "ttd"; + private const string App = "app"; + private const int InstanceOwnerPartyId = 1337; + private static readonly Guid _instanceId = Guid.NewGuid(); + + private readonly Mock _instanceMock = new(); + private readonly Mock _appMetadataMock = new(); + private readonly Mock _validationMock = new(); + private readonly Mock _dataClientMock = new(); + private readonly Mock _appModelMock = new(); + + public ValidateControllerTests() + { + _appMetadataMock + .Setup(a => a.GetApplicationMetadata()) + .ReturnsAsync(new ApplicationMetadata($"{Org}/{App}") { DataTypes = [] }); + } + [Fact] public async Task ValidateInstance_returns_NotFound_when_GetInstance_returns_null() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - Guid instanceId = new Guid(); - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(null!)); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); - var result = await validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId); + var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert Assert.IsType(result); @@ -46,31 +62,26 @@ public async Task ValidateInstance_returns_NotFound_when_GetInstance_returns_nul public async Task ValidateInstance_throws_ValidationException_when_Instance_Process_is_null() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - var instanceId = Guid.NewGuid(); Instance instance = new Instance { Id = "instanceId", Process = null }; - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(instance)); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); // Assert var exception = await Assert.ThrowsAsync( - () => validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId) + () => validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId) ); Assert.Equal("Unable to validate instance without a started process.", exception.Message); } @@ -79,35 +90,28 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc public async Task ValidateInstance_throws_ValidationException_when_Instance_Process_CurrentTask_is_null() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - var instanceId = Guid.NewGuid(); - Instance instance = new Instance { Id = "instanceId", Process = new ProcessState { CurrentTask = null } }; - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(instance)); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); // Assert var exception = await Assert.ThrowsAsync( - () => validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId) + () => validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId) ); Assert.Equal("Unable to validate instance without a started process.", exception.Message); } @@ -116,41 +120,46 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc public async Task ValidateInstance_returns_OK_with_messages() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - var instanceId = Guid.NewGuid(); Instance instance = new Instance { - Id = "instanceId", + Id = $"{InstanceOwnerPartyId}/{_instanceId}", + InstanceOwner = new() { PartyId = InstanceOwnerPartyId.ToString() }, + Org = Org, + AppId = $"{Org}/{App}", + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "dummy" } } }; - var validationResult = new List + var validationResult = new List() { - new ValidationIssue { Field = "dummy", Severity = ValidationIssueSeverity.Fixed } + new() + { + Code = "dummy", + Description = "dummy", + Field = "dummy", + Severity = ValidationIssueSeverity.Fixed, + Source = "dummy" + } }; - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(instance)); - validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", null)) - .Returns(Task.FromResult(validationResult)); + _validationMock + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .ReturnsAsync(validationResult); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); - var result = await validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId); + var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert result.Should().BeOfType().Which.Value.Should().BeEquivalentTo(validationResult); @@ -160,37 +169,35 @@ public async Task ValidateInstance_returns_OK_with_messages() public async Task ValidateInstance_returns_403_when_not_authorized() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - var instanceId = Guid.NewGuid(); - Instance instance = new Instance { - Id = "instanceId", + Id = $"{InstanceOwnerPartyId}/{_instanceId}", + InstanceOwner = new() { PartyId = InstanceOwnerPartyId.ToString() }, + Org = Org, + AppId = $"{Org}/{App}", Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "dummy" } } }; var updateProcessResult = new HttpResponseMessage(HttpStatusCode.Forbidden); PlatformHttpException exception = await PlatformHttpException.CreateAsync(updateProcessResult); - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(instance)); - validationMock.Setup(v => v.ValidateInstanceAtTask(instance, "dummy", null)).Throws(exception); + _validationMock + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .Throws(exception); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); - var result = await validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId); + var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert result.Should().BeOfType().Which.StatusCode.Should().Be(403); @@ -200,40 +207,38 @@ public async Task ValidateInstance_returns_403_when_not_authorized() public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() { // Arrange - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - - const string org = "ttd"; - const string app = "app"; - const int instanceOwnerPartyId = 1337; - var instanceId = Guid.NewGuid(); - Instance instance = new Instance { - Id = "instanceId", + Id = $"{InstanceOwnerPartyId}/{_instanceId}", + InstanceOwner = new() { PartyId = InstanceOwnerPartyId.ToString() }, + Org = Org, + AppId = $"{Org}/{App}", Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "dummy" } } }; var updateProcessResult = new HttpResponseMessage(HttpStatusCode.BadRequest); PlatformHttpException exception = await PlatformHttpException.CreateAsync(updateProcessResult); - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerPartyId, instanceId)) + _instanceMock + .Setup(i => i.GetInstance(App, Org, InstanceOwnerPartyId, _instanceId)) .Returns(Task.FromResult(instance)); - validationMock.Setup(v => v.ValidateInstanceAtTask(instance, "dummy", null)).Throws(exception); + _validationMock + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .Throws(exception); // Act var validateController = new ValidateController( - instanceMock.Object, - validationMock.Object, - appMetadataMock.Object + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object ); // Assert var thrownException = await Assert.ThrowsAsync( - () => validateController.ValidateInstance(org, app, instanceOwnerPartyId, instanceId) + () => validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId) ); Assert.Equal(exception, thrownException); } diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 688e1f35e..7d2f71663 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -1,7 +1,10 @@ using System.Collections; using Altinn.App.Api.Controllers; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -44,7 +47,7 @@ public class TestScenariosData : IEnumerable }, new("thows_ValidationException_when_Application_DataTypes_is_empty") { - DataGuid = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", "D"), + DataGuid = _dataGuid, ReceivedInstance = new Instance { Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "1234" } }, @@ -55,10 +58,14 @@ public class TestScenariosData : IEnumerable }, new("adds_ValidationIssue_when_DataType_TaskId_does_not_match_CurrentTask_ElementId") { - InstanceId = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354ef", "D"), - DataGuid = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", "D"), + InstanceId = _instanceId, + DataGuid = _dataGuid, ReceivedInstance = new Instance { + AppId = $"{ValidationControllerValidateDataTests.Org}/{ValidationControllerValidateDataTests.App}", + Org = ValidationControllerValidateDataTests.Org, + Id = $"{ValidationControllerValidateDataTests.InstanceOwnerId}/{_instanceId}", + InstanceOwner = new() { PartyId = ValidationControllerValidateDataTests.InstanceOwnerId.ToString() }, Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "1234" } }, Data = new List { @@ -73,13 +80,13 @@ public class TestScenariosData : IEnumerable { DataTypes = new List { - new DataType { Id = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", TaskId = "1234" } + new DataType { Id = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", TaskId = "Task_1" } } }, - ReceivedValidationIssues = new List(), - ExpectedValidationIssues = new List + ReceivedValidationIssues = new List(), + ExpectedValidationIssues = new List { - new ValidationIssue + new() { Code = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, Severity = ValidationIssueSeverity.Warning, @@ -89,17 +96,22 @@ public class TestScenariosData : IEnumerable new Dictionary>(), null, "nb" - ) - } + ), + Source = "source" + }, }, ExpectedResult = typeof(OkObjectResult) }, new("returns_ValidationIssues_from_ValidationService") { - InstanceId = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354ef", "D"), - DataGuid = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", "D"), + InstanceId = _instanceId, + DataGuid = _dataGuid, ReceivedInstance = new Instance { + AppId = $"{ValidationControllerValidateDataTests.Org}/{ValidationControllerValidateDataTests.App}", + Org = ValidationControllerValidateDataTests.Org, + Id = $"{ValidationControllerValidateDataTests.InstanceOwnerId}/{_instanceId}", + InstanceOwner = new() { PartyId = ValidationControllerValidateDataTests.InstanceOwnerId.ToString() }, Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd" } @@ -117,29 +129,36 @@ public class TestScenariosData : IEnumerable { DataTypes = new List { - new DataType { Id = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", TaskId = "1234" } + new DataType { Id = "0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", TaskId = "Task_1" } } }, - ReceivedValidationIssues = new List + ReceivedValidationIssues = new List { - new ValidationIssue + new() { Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, - Severity = ValidationIssueSeverity.Fixed - } + Description = "dummy", + Severity = ValidationIssueSeverity.Fixed, + Source = "source" + }, }, - ExpectedValidationIssues = new List + ExpectedValidationIssues = new List { - new ValidationIssue + new() { Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, - Severity = ValidationIssueSeverity.Fixed + Description = "dummy", + Severity = ValidationIssueSeverity.Fixed, + Source = "source" } }, ExpectedResult = typeof(OkObjectResult) } }; + private static readonly Guid _instanceId = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354ef", "D"); + private static readonly Guid _dataGuid = Guid.ParseExact("0fc98a23-fe31-4ef5-8fb9-dd3f479354cd", "D"); + public IEnumerator GetEnumerator() { List testData = new List(); @@ -159,24 +178,37 @@ IEnumerator IEnumerable.GetEnumerator() public class ValidationControllerValidateDataTests { + public const int InstanceOwnerId = 1337; + public const string App = "app-test"; + public const string Org = "ttd"; + private readonly Mock _instanceMock = new(MockBehavior.Strict); + private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly Mock _validationMock = new(MockBehavior.Strict); + private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _appModelMock = new(MockBehavior.Strict); + [Theory] [ClassData(typeof(TestScenariosData))] public async Task TestValidateData(ValidateDataTestScenario testScenario) { // Arrange - const string org = "ttd"; - const string app = "app-test"; - const int instanceOwnerId = 1337; - var validateController = SetupController(app, org, instanceOwnerId, testScenario); + SetupMocks(App, Org, InstanceOwnerId, testScenario); + var validateController = new ValidateController( + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + _appModelMock.Object + ); // Act and Assert if (testScenario.ExpectedExceptionMessage == null) { var result = await validateController.ValidateData( - org, - app, - instanceOwnerId, + Org, + App, + InstanceOwnerId, testScenario.InstanceId, testScenario.DataGuid ); @@ -187,9 +219,9 @@ public async Task TestValidateData(ValidateDataTestScenario testScenario) var exception = await Assert.ThrowsAsync( () => validateController.ValidateData( - org, - app, - instanceOwnerId, + Org, + App, + InstanceOwnerId, testScenario.InstanceId, testScenario.DataGuid ) @@ -198,41 +230,14 @@ public async Task TestValidateData(ValidateDataTestScenario testScenario) } } - private static ValidateController SetupController( - string app, - string org, - int instanceOwnerId, - ValidateDataTestScenario testScenario - ) + private void SetupMocks(string app, string org, int instanceOwnerId, ValidateDataTestScenario testScenario) { - ( - Mock instanceMock, - Mock appResourceMock, - Mock validationMock - ) = SetupMocks(app, org, instanceOwnerId, testScenario); - - return new ValidateController(instanceMock.Object, validationMock.Object, appResourceMock.Object); - } - - private static (Mock, Mock, Mock) SetupMocks( - string app, - string org, - int instanceOwnerId, - ValidateDataTestScenario testScenario - ) - { - var instanceMock = new Mock(); - var appMetadataMock = new Mock(); - var validationMock = new Mock(); - if (testScenario.ReceivedInstance != null) - { - instanceMock - .Setup(i => i.GetInstance(app, org, instanceOwnerId, testScenario.InstanceId)) - .Returns(Task.FromResult(testScenario.ReceivedInstance)); - } + _instanceMock + .Setup(i => i.GetInstance(app, org, instanceOwnerId, testScenario.InstanceId)) + .Returns(Task.FromResult(testScenario.ReceivedInstance)!); if (testScenario.ReceivedApplication != null) { - appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(testScenario.ReceivedApplication); + _appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(testScenario.ReceivedApplication); } if ( @@ -241,19 +246,17 @@ ValidateDataTestScenario testScenario && testScenario.ReceivedValidationIssues != null ) { - validationMock + _validationMock .Setup(v => - v.ValidateDataElement( + v.ValidateInstanceAtTask( testScenario.ReceivedInstance, - testScenario.ReceivedInstance.Data.First(), - testScenario.ReceivedApplication.DataTypes.First(), + "Task_1", + It.IsAny(), null ) ) - .Returns(Task.FromResult>(testScenario.ReceivedValidationIssues)); + .ReturnsAsync(testScenario.ReceivedValidationIssues); } - - return (instanceMock, appMetadataMock, validationMock); } } @@ -269,10 +272,10 @@ public ValidateDataTestScenario(string testScenarioName) public Guid DataGuid { get; init; } = Guid.NewGuid(); public Instance? ReceivedInstance { get; init; } public ApplicationMetadata? ReceivedApplication { get; init; } - public List? ReceivedValidationIssues { get; init; } + public List? ReceivedValidationIssues { get; init; } public string? ExpectedExceptionMessage { get; init; } public Type? ExpectedResult { get; init; } - public List? ExpectedValidationIssues { get; init; } + public List? ExpectedValidationIssues { get; init; } public override string ToString() { diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateController_ValidateInstanceTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateController_ValidateInstanceTests.cs index 9b991b5a9..a6d32bec2 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateController_ValidateInstanceTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateController_ValidateInstanceTests.cs @@ -44,7 +44,8 @@ ITestOutputHelper outputHelper ) : base(factory, outputHelper) { - _formDataValidatorMock.Setup(v => v.DataType).Returns("Not a valid data type"); + _formDataValidatorMock.Setup(v => v.DataType).Returns("9edd53de-f46f-40a1-bb4d-3efb93dc113d"); + _formDataValidatorMock.Setup(v => v.ValidationSource).Returns("Not a valid validation source"); OverrideServicesForAllTests = (services) => { services.AddSingleton(_dataProcessorMock.Object); diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index bb1bf0642..e867b7059 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -587,6 +587,155 @@ } } } + }, + "patch": { + "tags": [ + "Data" + ], + "parameters": [ + { + "name": "org", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "app", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "instanceOwnerPartyId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "instanceGuid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "language", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataPatchRequestMultiple" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DataPatchRequestMultiple" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DataPatchRequestMultiple" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DataPatchResponseMultiple" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataPatchResponseMultiple" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DataPatchResponseMultiple" + } + } + } + }, + "409": { + "description": "Conflict", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } } }, "/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/data/{dataGuid}": { @@ -4603,7 +4752,34 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + } + } } } } @@ -4669,7 +4845,8 @@ "200": { "description": "OK" } - } + }, + "deprecated": true } } }, @@ -5407,6 +5584,30 @@ }, "additionalProperties": false }, + "DataPatchRequestMultiple": { + "required": [ + "ignoredValidators", + "patches" + ], + "type": "object", + "properties": { + "patches": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/JsonPatch" + }, + "nullable": true + }, + "ignoredValidators": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "DataPatchResponse": { "required": [ "newDataModel", @@ -5419,7 +5620,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/ValidationIssue" + "$ref": "#/components/schemas/ValidationIssueWithSource" } }, "nullable": true @@ -5430,6 +5631,31 @@ }, "additionalProperties": false }, + "DataPatchResponseMultiple": { + "required": [ + "newDataModels", + "validationIssues" + ], + "type": "object", + "properties": { + "validationIssues": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + } + }, + "nullable": true + }, + "newDataModels": { + "type": "object", + "additionalProperties": { }, + "nullable": true + } + }, + "additionalProperties": false + }, "DataType": { "type": "object", "properties": { @@ -6639,7 +6865,7 @@ "additionalProperties": { "type": "array", "items": { - "$ref": "#/components/schemas/ValidationIssue" + "$ref": "#/components/schemas/ValidationIssueWithSource" } }, "nullable": true @@ -6677,9 +6903,24 @@ }, "additionalProperties": false }, - "ValidationIssue": { + "ValidationIssueSeverity": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "format": "int32" + }, + "ValidationIssueWithSource": { "required": [ - "severity" + "code", + "description", + "severity", + "source" ], "type": "object", "properties": { @@ -6720,18 +6961,6 @@ }, "additionalProperties": false }, - "ValidationIssueSeverity": { - "enum": [ - 0, - 1, - 2, - 3, - 4, - 5 - ], - "type": "integer", - "format": "int32" - }, "ValidationStatus": { "type": "object", "properties": { diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 8d1c48685..2293a370b 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -356,6 +356,96 @@ paths: text/xml: schema: $ref: '#/components/schemas/DataElement' + patch: + tags: + - Data + parameters: + - name: org + in: path + required: true + schema: + type: string + - name: app + in: path + required: true + schema: + type: string + - name: instanceOwnerPartyId + in: path + required: true + schema: + type: integer + format: int32 + - name: instanceGuid + in: path + required: true + schema: + type: string + format: uuid + - name: language + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DataPatchRequestMultiple' + text/json: + schema: + $ref: '#/components/schemas/DataPatchRequestMultiple' + application/*+json: + schema: + $ref: '#/components/schemas/DataPatchRequestMultiple' + responses: + '200': + description: OK + content: + text/plain: + schema: + $ref: '#/components/schemas/DataPatchResponseMultiple' + application/json: + schema: + $ref: '#/components/schemas/DataPatchResponseMultiple' + text/json: + schema: + $ref: '#/components/schemas/DataPatchResponseMultiple' + '409': + description: Conflict + content: + text/plain: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: Unprocessable Content + content: + text/plain: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' '/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/data/{dataGuid}': get: tags: @@ -2805,6 +2895,22 @@ paths: responses: '200': description: OK + content: + text/plain: + schema: + $ref: '#/components/schemas/ValidationIssueWithSource' + application/json: + schema: + $ref: '#/components/schemas/ValidationIssueWithSource' + text/json: + schema: + $ref: '#/components/schemas/ValidationIssueWithSource' + application/xml: + schema: + $ref: '#/components/schemas/ValidationIssueWithSource' + text/xml: + schema: + $ref: '#/components/schemas/ValidationIssueWithSource' '/{org}/{app}/instances/{instanceOwnerId}/{instanceId}/data/{dataGuid}/validate': get: tags: @@ -2845,6 +2951,7 @@ paths: responses: '200': description: OK + deprecated: true components: schemas: ActionError: @@ -3379,6 +3486,23 @@ components: type: string nullable: true additionalProperties: false + DataPatchRequestMultiple: + required: + - ignoredValidators + - patches + type: object + properties: + patches: + type: object + additionalProperties: + $ref: '#/components/schemas/JsonPatch' + nullable: true + ignoredValidators: + type: array + items: + type: string + nullable: true + additionalProperties: false DataPatchResponse: required: - newDataModel @@ -3390,11 +3514,29 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/ValidationIssue' + $ref: '#/components/schemas/ValidationIssueWithSource' nullable: true newDataModel: nullable: true additionalProperties: false + DataPatchResponseMultiple: + required: + - newDataModels + - validationIssues + type: object + properties: + validationIssues: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ValidationIssueWithSource' + nullable: true + newDataModels: + type: object + additionalProperties: { } + nullable: true + additionalProperties: false DataType: type: object properties: @@ -4273,7 +4415,7 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/ValidationIssue' + $ref: '#/components/schemas/ValidationIssueWithSource' nullable: true nullable: true clientActions: @@ -4297,9 +4439,22 @@ components: $ref: '#/components/schemas/KeyValueEntry' nullable: true additionalProperties: false - ValidationIssue: + ValidationIssueSeverity: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + type: integer + format: int32 + ValidationIssueWithSource: required: + - code + - description - severity + - source type: object properties: severity: @@ -4328,16 +4483,6 @@ components: type: string nullable: true additionalProperties: false - ValidationIssueSeverity: - enum: - - 0 - - 1 - - 2 - - 3 - - 4 - - 5 - type: integer - format: int32 ValidationStatus: type: object properties: diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs index fa44e4ca3..68091279f 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs @@ -122,7 +122,7 @@ public async Task ValidateFormData_RequiredProperty() "field": "range", "code": "The field RangeProperty must be between 1 and 10.", "description": "The field RangeProperty must be between 1 and 10.", - "source": "ModelState", + "source": null, "customTextKey": null }, { @@ -132,7 +132,7 @@ public async Task ValidateFormData_RequiredProperty() "field": "requiredProperty", "code": "The RequiredProperty field is required.", "description": "The RequiredProperty field is required.", - "source": "ModelState", + "source": null, "customTextKey": null }, { @@ -142,7 +142,7 @@ public async Task ValidateFormData_RequiredProperty() "field": "NestedProperty.range", "code": "The field RangeProperty must be between 1 and 10.", "description": "The field RangeProperty must be between 1 and 10.", - "source": "ModelState", + "source": null, "customTextKey": null }, { @@ -152,7 +152,7 @@ public async Task ValidateFormData_RequiredProperty() "field": "NestedProperty.requiredProperty", "code": "The RequiredProperty field is required.", "description": "The RequiredProperty field is required.", - "source": "ModelState", + "source": null, "customTextKey": null } ] diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs index 93da0336a..00f3268a7 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -4,6 +4,8 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; @@ -15,34 +17,41 @@ namespace Altinn.App.Core.Tests.Features.Validators.Default; public class LegacyIValidationFormDataTests { private readonly LegacyIInstanceValidatorFormDataValidator _validator; - private readonly Mock _instanceValidator = new(); + private readonly Mock _instanceValidator = new(MockBehavior.Strict); + private readonly Mock _appMetadata = new(MockBehavior.Strict); + private readonly Mock _instanceDataAccessor = new(MockBehavior.Strict); + + private readonly ApplicationMetadata _applicationMetadata = new ApplicationMetadata("ttd/test") + { + Title = new LanguageString() { { "nb", "test" } }, + DataTypes = new List() + { + new DataType() { Id = "test", TaskId = "Task_1" }, + }, + }; + + private readonly Guid _dataId = Guid.NewGuid(); + private readonly DataElement _dataElement; + private readonly Instance _instance; public LegacyIValidationFormDataTests() { var generalSettings = new GeneralSettings(); _validator = new LegacyIInstanceValidatorFormDataValidator( Microsoft.Extensions.Options.Options.Create(generalSettings), - _instanceValidator.Object + _instanceValidator.Object, + _appMetadata.Object ); - } - - [Fact] - public async Task ValidateFormData_NoErrors() - { - // Arrange - var data = new object(); - - var validator = new LegacyIInstanceValidatorFormDataValidator( - Microsoft.Extensions.Options.Options.Create(new GeneralSettings()), - null - ); - validator.HasRelevantChanges(data, data).Should().BeFalse(); - - // Act - var result = await validator.ValidateFormData(new Instance(), new DataElement(), data, null); - - // Assert - Assert.Empty(result); + _appMetadata.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); + + _dataElement = new DataElement() { DataType = "test", Id = _dataId.ToString(), }; + _instance = new Instance() + { + AppId = "test", + Org = "test", + InstanceOwner = new InstanceOwner() { PartyId = "1", }, + Data = [_dataElement] + }; } [Fact] @@ -53,48 +62,53 @@ public async Task ValidateFormData_WithErrors() _instanceValidator .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) - .Callback( + .Returns( (object _, ModelStateDictionary modelState) => { modelState.AddModelError("test", "test"); modelState.AddModelError("ddd", "*FIXED*test"); + return Task.CompletedTask; } - ); + ) + .Verifiable(Times.Once); + + _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data); // Act - var result = await _validator.ValidateFormData(new Instance(), new DataElement(), data, null); + var result = await _validator.Validate(_instance, "Task_1", null, _instanceDataAccessor.Object); // Assert result .Should() .BeEquivalentTo( JsonSerializer.Deserialize>( - """ + $$""" [ { "severity": 4, "instanceId": null, - "dataElementId": null, + "dataElementId": "{{_dataId}}", "field": "ddd", "code": "test", "description": "test", - "source": "Custom", "customTextKey": null }, { "severity": 1, "instanceId": null, - "dataElementId": null, + "dataElementId": "{{_dataId}}", "field": "test", "code": "test", "description": "test", - "source": "Custom", "customTextKey": null } ] """ ) ); + + _instanceDataAccessor.Verify(); + _instanceValidator.Verify(); } private class TestModel @@ -128,16 +142,19 @@ public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string _instanceValidator .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) - .Callback( + .Returns( (object _, ModelStateDictionary modelState) => { modelState.AddModelError(errorKey, errorMessage); modelState.AddModelError(errorKey, "*FIXED*" + errorMessage + " Fixed"); + return Task.CompletedTask; } - ); + ) + .Verifiable(Times.Once); + _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); // Act - var result = await _validator.ValidateFormData(new Instance(), new DataElement(), data, null); + var result = await _validator.Validate(_instance, "Task_1", null, _instanceDataAccessor.Object); // Assert result.Should().HaveCount(2); @@ -150,5 +167,8 @@ public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string fixedIssue.Field.Should().Be(field); fixedIssue.Severity.Should().Be(ValidationIssueSeverity.Fixed); fixedIssue.Description.Should().Be(errorMessage + " Fixed"); + + _instanceDataAccessor.Verify(); + _instanceValidator.Verify(); } } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs index 378b21155..74eb9dc28 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Features.Validation.Helpers; @@ -21,10 +22,10 @@ namespace Altinn.App.Core.Tests.Features.Validators; public class ValidationServiceOldTests { private readonly Mock> _loggerMock = new(); - private readonly Mock _dataClientMock = new(); - private readonly Mock _appModelMock = new(); - private readonly Mock _appMetadataMock = new(); private readonly Mock _httpContextAccessorMock = new(); + private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _appModelMock = new(MockBehavior.Strict); + private readonly Mock _appMetadataMock = new(MockBehavior.Strict); private readonly ServiceCollection _serviceCollection = new(); private readonly ApplicationMetadata _applicationMetadata = @@ -52,11 +53,7 @@ public ValidationServiceOldTests() _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(); - _serviceCollection.AddScoped(); - - _httpContextAccessorMock.SetupGet(h => h.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); - _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); - + _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new GeneralSettings())); _appMetadataMock.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); } @@ -66,18 +63,25 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() await using var serviceProvider = _serviceCollection.BuildServiceProvider(); IValidationService validationService = serviceProvider.GetRequiredService(); - var instance = new Instance(); - var dataType = new DataType() { EnableFileScan = true }; - var dataElement = new DataElement() { DataType = "test", FileScanResult = FileScanResult.Infected }; + var dataType = new DataType() + { + Id = "testScan", + TaskId = "Task_1", + EnableFileScan = true + }; + _applicationMetadata.DataTypes.Add(dataType); + var dataElement = new DataElement() { DataType = "testScan", FileScanResult = FileScanResult.Infected }; + var instance = new Instance() { Data = [dataElement] }; + var dataAccessor = new Mock(); - List validationIssues = await validationService.ValidateDataElement( + List validationIssues = await validationService.ValidateInstanceAtTask( instance, - dataElement, - dataType, + "Task_1", + dataAccessor.Object, null ); - validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileInfected").Should().NotBeNull(); + validationIssues.Should().ContainSingle(vi => vi.Code == "DataElementFileInfected"); } [Fact] @@ -93,13 +97,15 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( AppLogic = null, EnableFileScan = true }; - var instance = new Instance() { }; var dataElement = new DataElement() { DataType = "test", FileScanResult = FileScanResult.Pending, }; + var instance = new Instance() { Data = [dataElement] }; - List validationIssues = await validationService.ValidateDataElement( + var dataAccessorMock = new Mock(); + + List validationIssues = await validationService.ValidateInstanceAtTask( instance, - dataElement, - dataType, + "Task_1", + dataAccessorMock.Object, null ); @@ -112,18 +118,26 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() await using var serviceProvider = _serviceCollection.BuildServiceProvider(); IValidationService validationService = serviceProvider.GetRequiredService(); - var instance = new Instance(); - var dataType = new DataType() { EnableFileScan = true, ValidationErrorOnPendingFileScan = true }; - var dataElement = new DataElement() { DataType = "test", FileScanResult = FileScanResult.Pending }; + var dataType = new DataType() + { + Id = "testScan", + TaskId = "Task_1", + EnableFileScan = true, + ValidationErrorOnPendingFileScan = true + }; + _applicationMetadata.DataTypes.Add(dataType); + var dataElement = new DataElement() { DataType = "testScan", FileScanResult = FileScanResult.Pending }; + var instance = new Instance() { Data = [dataElement], }; + var dataAccessorMock = new Mock(); - List validationIssues = await validationService.ValidateDataElement( + List validationIssues = await validationService.ValidateInstanceAtTask( instance, - dataElement, - dataType, + "Task_1", + dataAccessorMock.Object, null ); - validationIssues.FirstOrDefault(vi => vi.Code == "DataElementFileScanPending").Should().NotBeNull(); + validationIssues.Should().ContainSingle(vi => vi.Code == "DataElementFileScanPending"); } [Fact] @@ -132,14 +146,21 @@ public async Task FileScanEnabled_Clean_ValidationShouldNotFail() await using var serviceProvider = _serviceCollection.BuildServiceProvider(); IValidationService validationService = serviceProvider.GetRequiredService(); - var instance = new Instance(); var dataType = new DataType() { EnableFileScan = true, ValidationErrorOnPendingFileScan = true }; var dataElement = new DataElement() { DataType = "test", FileScanResult = FileScanResult.Clean, }; + var instance = new Instance() + { + AppId = "ttd/test-app", + Org = "ttd", + Data = [dataElement] + }; + + var dataAccessorMock = new Mock(); - List validationIssues = await validationService.ValidateDataElement( + List validationIssues = await validationService.ValidateInstanceAtTask( instance, - dataElement, - dataType, + "Task_1", + dataAccessorMock.Object, null ); @@ -177,10 +198,11 @@ public async Task ValidateAndUpdateProcess_set_canComplete_validationstatus_and_ { new() { DataType = "data", ContentType = "application/json" }, }, - Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task_1" } } + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "Task_1" } } }; + var dataAccessorMock = new Mock(); - var issues = await validationService.ValidateInstanceAtTask(instance, taskId, null); + var issues = await validationService.ValidateInstanceAtTask(instance, taskId, dataAccessorMock.Object, null); issues.Should().BeEmpty(); // instance.Process?.CurrentTask?.Validated.CanCompleteTask.Should().BeTrue(); @@ -228,15 +250,13 @@ public async Task ValidateAndUpdateProcess_set_canComplete_false_validationstatu ContentType = "application/json" }, }, - Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task_1" } } + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "Task_1" } } }; + var dataAccessorMock = new Mock(); - var issues = await validationService.ValidateInstanceAtTask(instance, taskId, null); + var issues = await validationService.ValidateInstanceAtTask(instance, taskId, dataAccessorMock.Object, null); issues.Should().HaveCount(1); issues.Should().ContainSingle(i => i.Code == ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType); - - // instance.Process?.CurrentTask?.Validated.CanCompleteTask.Should().BeFalse(); - // instance.Process?.CurrentTask?.Validated.Timestamp.Should().NotBeNull(); } [Fact] diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 988a4edff..fdb010c18 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -28,18 +29,18 @@ private class MyModel } private const int DefaultPartyId = 234; - private static readonly Guid DefaultInstanceId = Guid.NewGuid(); - private static readonly Guid DefaultDataElementId = Guid.NewGuid(); + private static readonly Guid _defaultInstanceId = Guid.NewGuid(); + private static readonly Guid _defaultDataElementId = Guid.NewGuid(); private const string DefaultTaskId = "Task_1"; private const string DefaultOrg = "org"; private const string DefaultApp = "app"; private const string DefaultAppId = $"{DefaultOrg}/{DefaultApp}"; private const string DefaultLanguage = "defaultLanguageCode"; - private static readonly DataElement DefaultDataElement = - new() { Id = DefaultDataElementId.ToString(), DataType = "MyType", }; + private static readonly DataElement _defaultDataElement = + new() { Id = _defaultDataElementId.ToString(), DataType = "MyType", }; - private static readonly DataType DefaultDataType = + private static readonly DataType _defaultDataType = new() { Id = "MyType", @@ -47,23 +48,36 @@ private class MyModel AppLogic = new ApplicationLogic { ClassRef = typeof(MyModel).FullName } }; - private static readonly Instance DefaultInstance = + private static readonly DataType _neverataType = new() { - Id = $"{DefaultPartyId}/{DefaultInstanceId}", + Id = "never", + TaskId = DefaultTaskId, + AppLogic = new ApplicationLogic { ClassRef = typeof(MyModel).FullName } + }; + + private static readonly Instance _defaultInstance = + new() + { + Id = $"{DefaultPartyId}/{_defaultInstanceId}", InstanceOwner = new InstanceOwner() { PartyId = DefaultPartyId.ToString(), }, Org = DefaultOrg, AppId = DefaultAppId, - Data = [DefaultDataElement], - Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task1" } } + Data = [_defaultDataElement], + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "Task_1" } } }; - private static readonly ApplicationMetadata DefaultAppMetadata = - new(DefaultAppId) { DataTypes = new List { DefaultDataType, }, }; + private static readonly ApplicationMetadata _defaultAppMetadata = + new(DefaultAppId) + { + DataTypes = new List { _defaultDataType, _neverataType }, + }; private readonly Mock> _loggerMock = new(); private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly IInstanceDataAccessor _dataAccessor; + private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); @@ -97,19 +111,26 @@ private class MyModel public ValidationServiceTests() { + _dataAccessor = new CachedInstanceDataAccessor( + _defaultInstance, + _dataClientMock.Object, + _appMetadataMock.Object, + _appModelMock.Object + ); _serviceCollection.AddSingleton(_loggerMock.Object); _serviceCollection.AddSingleton(_dataClientMock.Object); _serviceCollection.AddSingleton(); _serviceCollection.AddSingleton(_appModelMock.Object); _appModelMock.Setup(a => a.GetModelType(typeof(MyModel).FullName!)).Returns(typeof(MyModel)); _serviceCollection.AddSingleton(_appMetadataMock.Object); - _appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(DefaultAppMetadata); + _appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(_defaultAppMetadata); _serviceCollection.AddSingleton(); - _serviceCollection.AddScoped(); _httpContextAccessorMock.Setup(h => h.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); + _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new GeneralSettings())); + // NeverUsedValidators _serviceCollection.AddSingleton(_taskValidatorNeverMock.Object); SetupTaskValidatorType(_taskValidatorNeverMock, "never", "neverTask"); @@ -122,9 +143,9 @@ public ValidationServiceTests() _serviceCollection.AddSingleton(_taskValidatorMock.Object); SetupTaskValidatorType(_taskValidatorMock, DefaultTaskId, "specificTaskValidator"); _serviceCollection.AddSingleton(_dataElementValidatorMock.Object); - SetupDataElementValidatorType(_dataElementValidatorMock, DefaultDataType.Id, "specificDataElementValidator"); + SetupDataElementValidatorType(_dataElementValidatorMock, _defaultDataType.Id, "specificDataElementValidator"); _serviceCollection.AddSingleton(_formDataValidatorMock.Object); - SetupFormDataValidatorType(_formDataValidatorMock, DefaultDataType.Id, "specificValidator"); + SetupFormDataValidatorType(_formDataValidatorMock, _defaultDataType.Id, "specificValidator"); // AlwaysUsedValidators _serviceCollection.AddSingleton(_taskValidatorAlwaysMock.Object); @@ -148,7 +169,7 @@ private void SetupTaskValidatorReturn( ) { taskValidatorMock - .Setup(v => v.ValidateTask(DefaultInstance, DefaultTaskId, DefaultLanguage)) + .Setup(v => v.ValidateTask(_defaultInstance, DefaultTaskId, DefaultLanguage)) .ReturnsAsync(validationIssues) .Verifiable(times ?? Times.Once()); } @@ -170,7 +191,7 @@ private void SetupDataElementValidatorReturn( ) { dataElementValidatorMock - .Setup(v => v.ValidateDataElement(DefaultInstance, DefaultDataElement, DefaultDataType, DefaultLanguage)) + .Setup(v => v.ValidateDataElement(_defaultInstance, _defaultDataElement, _defaultDataType, DefaultLanguage)) .ReturnsAsync(validationIssues) .Verifiable(times ?? Times.Once()); } @@ -197,7 +218,7 @@ private void SetupFormDataValidatorReturn( { // ValidateFormData formDataValidatorMock - .Setup(v => v.ValidateFormData(DefaultInstance, DefaultDataElement, It.IsAny(), DefaultLanguage)) + .Setup(v => v.ValidateFormData(_defaultInstance, _defaultDataElement, It.IsAny(), DefaultLanguage)) .ReturnsAsync((Instance instance, DataElement dataElement, MyModel data, string? language) => func(data)) .Verifiable(hasRelevantChanges is not false ? (times ?? Times.Once()) : Times.Never()); @@ -208,12 +229,12 @@ private void SetupFormDataValidatorReturn( .Verifiable(hasRelevantChanges is null ? Times.Never : Times.AtLeastOnce); } - private void SourcePropertyIsSet(Dictionary> result) + private void SourcePropertyIsSet(Dictionary> result) { Assert.All(result.Values, SourcePropertyIsSet); } - private void SourcePropertyIsSet(List result) + private void SourcePropertyIsSet(List result) { Assert.All( result, @@ -230,12 +251,12 @@ private void SetupDataClient(MyModel data) _dataClientMock .Setup(d => d.GetFormData( - DefaultInstanceId, + _defaultInstanceId, data.GetType(), DefaultOrg, DefaultApp, DefaultPartyId, - DefaultDataElementId + _defaultDataElementId ) ) .ReturnsAsync(data) @@ -253,30 +274,14 @@ public async Task Validate_WithNoValidators_ReturnsNoErrors() await using var serviceProvider = _serviceCollection.BuildServiceProvider(); var validatorService = serviceProvider.GetRequiredService(); - var data = new MyModel { Name = "Ola" }; - SetupDataClient(data); - - var resultTask = await validatorService.ValidateInstanceAtTask(DefaultInstance, DefaultTaskId, DefaultLanguage); - resultTask.Should().BeEmpty(); - var resultElement = await validatorService.ValidateDataElement( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - DefaultLanguage - ); - resultElement.Should().BeEmpty(); - - var resultData = await validatorService.ValidateFormData( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - data, - null, - null, + var resultTask = await validatorService.ValidateInstanceAtTask( + _defaultInstance, + DefaultTaskId, + _dataAccessor, DefaultLanguage ); - resultData.Should().BeEmpty(); + resultTask.Should().BeEmpty(); } [Fact] @@ -311,12 +316,20 @@ public async Task ValidateFormData_WithSpecificValidator() var validatorService = serviceProvider.GetRequiredService(); var data = new MyModel { Name = "Ola" }; var previousData = new MyModel() { Name = "Kari" }; - var result = await validatorService.ValidateFormData( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - data, - previousData, + SetupDataClient(data); + var result = await validatorService.ValidateIncrementalFormData( + _defaultInstance, + "Task_1", + new List + { + new() + { + DataElement = _defaultDataElement, + PreviousValue = previousData, + CurrentValue = data + } + }, + _dataAccessor, null, DefaultLanguage ); @@ -331,7 +344,7 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa { SetupFormDataValidatorReturn( _formDataValidatorMock, - hasRelevantChanges: null, + hasRelevantChanges: true, model => new List { new() { Severity = ValidationIssueSeverity.Error, CustomTextKey = "NameNotOla" } @@ -340,7 +353,7 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa SetupFormDataValidatorReturn( _formDataValidatorAlwaysMock, - hasRelevantChanges: null, + hasRelevantChanges: true, model => new List { new() { Severity = ValidationIssueSeverity.Error, CustomTextKey = "AlwaysNameNotOla" } @@ -350,13 +363,26 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa await using var serviceProvider = _serviceCollection.BuildServiceProvider(); var validatorService = serviceProvider.GetRequiredService(); + var dataAccessor = new CachedInstanceDataAccessor( + _defaultInstance, + _dataClientMock.Object, + _appMetadataMock.Object, + _appModelMock.Object + ); var data = new MyModel { Name = "Kari" }; - var resultData = await validatorService.ValidateFormData( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - data, - null, + dataAccessor.Set(_defaultDataElement, data); + var resultData = await validatorService.ValidateIncrementalFormData( + _defaultInstance, + "Task_1", + [ + new DataElementChange() + { + DataElement = _defaultDataElement, + CurrentValue = data, + PreviousValue = data, + } + ], + dataAccessor, null, DefaultLanguage ); @@ -396,37 +422,45 @@ List CreateIssues(string code) SetupDataElementValidatorReturn( _dataElementValidatorMock, CreateIssues("data_element_validator"), - Times.Exactly(2) + Times.Once() ); SetupDataElementValidatorReturn( _dataElementValidatorAlwaysMock, CreateIssues("data_element_validator_always"), - Times.Exactly(2) + Times.Once() ); SetupFormDataValidatorReturn( _formDataValidatorMock, hasRelevantChanges: null, /* should not call HasRelevantChanges */ model => CreateIssues("form_data_validator"), - Times.Exactly(3) + Times.Once() ); SetupFormDataValidatorReturn( _formDataValidatorAlwaysMock, hasRelevantChanges: null, /* should not call HasRelevantChanges */ model => CreateIssues("form_data_validator_always"), - Times.Exactly(3) + Times.Once() ); var data = new MyModel(); SetupDataClient(data); - using var serviceProvider = _serviceCollection.BuildServiceProvider(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); var validationService = serviceProvider.GetRequiredService(); + var dataAccessor = new CachedInstanceDataAccessor( + _defaultInstance, + _dataClientMock.Object, + _appMetadataMock.Object, + _appModelMock.Object + ); + var taskResult = await validationService.ValidateInstanceAtTask( - DefaultInstance, + _defaultInstance, DefaultTaskId, + dataAccessor, DefaultLanguage ); @@ -462,61 +496,6 @@ List CreateIssues(string code) .Be(ValidationIssueSeverity.Error); taskResult.Should().HaveCount(6); SourcePropertyIsSet(taskResult); - - var elementResult = await validationService.ValidateDataElement( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - DefaultLanguage - ); - elementResult - .Should() - .Contain(i => i.Code == "data_element_validator") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - elementResult - .Should() - .Contain(i => i.Code == "data_element_validator_always") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - elementResult - .Should() - .Contain(i => i.Code == "form_data_validator") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - elementResult - .Should() - .Contain(i => i.Code == "form_data_validator_always") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - elementResult.Should().HaveCount(4); - SourcePropertyIsSet(elementResult); - - var dataResult = await validationService.ValidateFormData( - DefaultInstance, - DefaultDataElement, - DefaultDataType, - data, - null, - null, - DefaultLanguage - ); - dataResult - .Should() - .ContainKey("specificValidator") - .WhoseValue.Should() - .ContainSingle(i => i.Code == "form_data_validator") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - dataResult - .Should() - .ContainKey("alwaysUsedValidator") - .WhoseValue.Should() - .ContainSingle(i => i.Code == "form_data_validator_always") - .Which.Severity.Should() - .Be(ValidationIssueSeverity.Error); - dataResult.Should().HaveCount(2); - SourcePropertyIsSet(dataResult); } [Fact] @@ -537,10 +516,15 @@ public async Task ValidateTask_ReturnsNoErrorsFromAllLevels() var data = new MyModel(); SetupDataClient(data); - using var serviceProvider = _serviceCollection.BuildServiceProvider(); + await using var serviceProvider = _serviceCollection.BuildServiceProvider(); var validationService = serviceProvider.GetRequiredService(); - var result = await validationService.ValidateInstanceAtTask(DefaultInstance, DefaultTaskId, DefaultLanguage); + var result = await validationService.ValidateInstanceAtTask( + _defaultInstance, + DefaultTaskId, + _dataAccessor, + DefaultLanguage + ); result.Should().BeEmpty(); } @@ -559,6 +543,11 @@ public void Dispose() _dataElementValidatorAlwaysMock.Verify(); _formDataValidatorAlwaysMock.Verify(); + _dataClientMock.Verify(); + _appMetadataMock.Verify(); + _appModelMock.Verify(); + _loggerMock.Verify(); + _dataClientMock.Verify(); } } diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.Test_Ok.verified.txt b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.Test_Ok.verified.txt index 5a0488bdb..5e70663ef 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.Test_Ok.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.Test_Ok.verified.txt @@ -1,28 +1,18 @@ { Activities: [ + { + ActivityName: Data.ProcessWrite.IDataProcessorProxy, + IdFormat: W3C + }, { ActivityName: Data.Patch, Tags: [ { instance.guid: Guid_1 - }, - { - result: success } ], IdFormat: W3C } ], - Metrics: [ - { - altinn_app_lib_data_patched: [ - { - Value: 1, - Tags: { - result: success - } - } - ] - } - ] -} \ No newline at end of file + Metrics: [] +} diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 9b6a4c270..087d51184 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Altinn.App.Common.Tests; +using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -14,6 +15,7 @@ using Json.Pointer; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; using DataType = Altinn.Platform.Storage.Interface.Models.DataType; @@ -22,13 +24,17 @@ namespace Altinn.App.Core.Tests.Internal.Patch; public class PatchServiceTests : IDisposable { // Test data - private static readonly Guid DataGuid = new("12345678-1234-1234-1234-123456789123"); + private static readonly Guid _dataGuid = new("12345678-1234-1234-1234-123456789123"); private readonly Instance _instance = new() { Id = "1337/12345678-1234-1234-1234-12345678912a", - Process = new ProcessState { CurrentTask = new ProcessElementInfo { Name = "Task_1" } } + AppId = "ttd/test", + Org = "ttd", + InstanceOwner = new() { PartyId = "1337" }, + Data = [_dataElement], + Process = new() { CurrentTask = new() { ElementId = "Task_1" }, } }; // Service mocks @@ -49,10 +55,11 @@ public class PatchServiceTests : IDisposable public PatchServiceTests() { + var applicationMetadata = new ApplicationMetadata("ttd/test") { DataTypes = [_dataType], }; _appMetadataMock .Setup(a => a.GetApplicationMetadata()) - .ReturnsAsync(new ApplicationMetadata("ttd/test")) - .Verifiable(); + .ReturnsAsync(applicationMetadata) + .Verifiable(Times.AtLeastOnce); _appModelMock .Setup(a => a.GetModelType("Altinn.App.Core.Tests.Internal.Patch.PatchServiceTests+MyModel")) .Returns(typeof(MyModel)) @@ -60,6 +67,8 @@ public PatchServiceTests() _formDataValidator.Setup(fdv => fdv.DataType).Returns(_dataType.Id); _formDataValidator.Setup(fdv => fdv.ValidationSource).Returns("formDataValidator"); _formDataValidator.Setup(fdv => fdv.HasRelevantChanges(It.IsAny(), It.IsAny())).Returns(true); + _dataElementValidator.Setup(dev => dev.DataType).Returns(_dataType.Id); + _dataElementValidator.Setup(dev => dev.ValidationSource).Returns("dataElementValidator"); _dataClientMock .Setup(d => d.UpdateData( @@ -74,8 +83,15 @@ public PatchServiceTests() ) .ReturnsAsync(_dataElement) .Verifiable(); - _httpContextAccessorMock.SetupGet(hca => hca.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); - var validatorFactory = new ValidatorFactory([], [_dataElementValidator.Object], [_formDataValidator.Object]); + var validatorFactory = new ValidatorFactory( + [], + Options.Create(new GeneralSettings()), + [_dataElementValidator.Object], + [_formDataValidator.Object], + [], + [], + _appMetadataMock.Object + ); var validationService = new ValidationService( validatorFactory, _dataClientMock.Object, @@ -100,14 +116,15 @@ public PatchServiceTests() ); } - private readonly DataType _dataType = + private static readonly DataType _dataType = new() { Id = "dataTypeId", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Patch.PatchServiceTests+MyModel" } + AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Patch.PatchServiceTests+MyModel" }, + TaskId = "Task_1", }; - private readonly DataElement _dataElement = new() { Id = DataGuid.ToString(), DataType = "dataTypeId" }; + private static readonly DataElement _dataElement = new() { Id = _dataGuid.ToString(), DataType = _dataType.Id }; private class MyModel { @@ -162,21 +179,20 @@ public async Task Test_Ok() .ReturnsAsync(validationIssues); // Act - var response = await _patchService.ApplyPatch( - _instance, - _dataType, - _dataElement, - jsonPatch, - null, - ignoredValidators - ); + var patches = new Dictionary() { { _dataGuid, jsonPatch } }; + var response = await _patchService.ApplyPatches(_instance, patches, null, ignoredValidators); // Assert response.Should().NotBeNull(); response.Success.Should().BeTrue(); response.Ok.Should().NotBeNull(); var res = response.Ok!; - res.NewDataModel.Should().BeOfType().Subject.Name.Should().Be("Test Testesen"); + res.NewDataModels.Should() + .ContainKey(_dataGuid) + .WhoseValue.Should() + .BeOfType() + .Subject.Name.Should() + .Be("Test Testesen"); var validator = res.ValidationIssues.Should().ContainSingle().Which; validator.Key.Should().Be("formDataValidator"); var issue = validator.Value.Should().ContainSingle().Which; @@ -238,14 +254,8 @@ public async Task Test_JsonPatchTest_fail() .ReturnsAsync(validationIssues); // Act - var response = await _patchService.ApplyPatch( - _instance, - _dataType, - _dataElement, - jsonPatch, - null, - ignoredValidators - ); + var patches = new Dictionary() { { _dataGuid, jsonPatch } }; + var response = await _patchService.ApplyPatches(_instance, patches, null, ignoredValidators); // Assert response.Should().NotBeNull(); @@ -306,14 +316,8 @@ public async Task Test_JsonPatch_does_not_deserialize() .ReturnsAsync(validationIssues); // Act - var response = await _patchService.ApplyPatch( - _instance, - _dataType, - _dataElement, - jsonPatch, - null, - ignoredValidators - ); + var patches = new Dictionary() { { _dataGuid, jsonPatch } }; + var response = await _patchService.ApplyPatches(_instance, patches, null, ignoredValidators); // Assert response.Should().NotBeNull(); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/Elements/AppProcessStateTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/Elements/AppProcessStateTests.cs index ccc02868c..266332c3e 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/Elements/AppProcessStateTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/Elements/AppProcessStateTests.cs @@ -21,7 +21,7 @@ public void Constructor_with_ProcessState_copies_values() Started = DateTime.Now, Ended = DateTime.Now, Flow = 2, - Name = "Task_1", + Name = "Utfylling", Validated = new() { Timestamp = DateTime.Now, CanCompleteTask = false }, ElementId = "Task_1", FlowType = "FlowType", @@ -71,7 +71,7 @@ public void Constructor_with_ProcessState_copies_values_validated_null() Started = DateTime.Now, Ended = DateTime.Now, Flow = 2, - Name = "Task_1", + Name = "Utfylling", Validated = null, ElementId = "Task_1", FlowType = "FlowType", diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 793354ff4..1ec884a46 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -300,12 +300,11 @@ private ExpressionsExclusiveGateway SetupExpressionsGateway( var frontendSettings = Options.Create(new FrontEndSettings()); - _httpContextAccessor.SetupGet(hca => hca.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); - var layoutStateInit = new LayoutEvaluatorStateInitializer( _resources.Object, frontendSettings, - new CachedFormDataAccessor( + new CachedInstanceDataAccessor( + _instance, _dataClient.Object, _appMetadata.Object, _appModel.Object, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 95b3d448a..c02289b6e 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -235,7 +235,9 @@ public void Ensure_tests_For_All_Folders() // This is just a way to ensure that all folders have test methods associcated. var jsonTestFolders = Directory .GetDirectories(Path.Join("LayoutExpressions", "CommonTests", "shared-tests", "functions")) + .Where(d => Directory.GetFiles(d).Length > 0) .Select(d => Path.GetFileName(d)) + .OrderBy(d => d) .ToArray(); var testMethods = this.GetType() .GetMethods() @@ -245,10 +247,9 @@ public void Ensure_tests_For_All_Folders() .Value ) .OfType() + .OrderBy(d => d) .ToArray(); - testMethods - .Should() - .BeEquivalentTo(jsonTestFolders, "Shared test folders should have a corresponding test method"); + testMethods.Should().Equal(jsonTestFolders, "Shared test folders should have a corresponding test method"); } } From f7b91e0a6627169a56ad63823557d2a137640d96 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 19 Aug 2024 14:17:20 +0200 Subject: [PATCH 08/63] Fix tests and cleanup --- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../Features/IProcessExclusiveGateway.cs | 2 + src/Altinn.App.Core/Features/IValidator.cs | 11 +- .../Default/DataAnnotationValidator.cs | 2 +- .../Default/DefaultDataElementValidator.cs | 2 +- .../Default/DefaultTaskValidator.cs | 2 +- .../Validation/Default/ExpressionValidator.cs | 51 +++++++-- ...gacyIInstanceValidatorFormDataValidator.cs | 4 +- .../LegacyIInstanceValidatorTaskValidator.cs | 4 +- .../Validation/Default/RequiredValidator.cs | 30 ++--- .../Wrappers/DataElementValidatorWrapper.cs | 4 +- .../Wrappers/FormDataValidatorWrapper.cs | 4 +- .../Wrappers/TaskValidatorWrapper.cs | 4 +- src/Altinn.App.Core/Helpers/ObjectUtils.cs | 13 ++- .../Internal/Data/CachedFormDataAccessor.cs | 14 ++- .../ILayoutEvaluatorStateInitializer.cs | 2 + .../LayoutEvaluatorStateInitializer.cs | 11 +- .../Process/ExpressionsExclusiveGateway.cs | 9 +- .../Internal/Process/ProcessNavigator.cs | 31 +++++- .../Common/ProcessTaskFinalizer.cs | 105 ++++++++++++------ .../Internal/Validation/ValidationService.cs | 4 +- .../Default/ExpressionValidatorTests.cs | 34 ++++-- .../Default/LegacyIValidationFormDataTests.cs | 4 +- .../Validators/ValidationServiceTests.cs | 5 - .../Internal/Patch/PatchServiceTests.cs | 14 +-- .../ExpressionsExclusiveGatewayTests.cs | 62 ++++++----- .../Internal/Process/ProcessNavigatorTests.cs | 48 +++++--- .../StubGatewayFilters/DataValuesFilter.cs | 1 + .../FullTests/LayoutTestUtils.cs | 10 +- 29 files changed, 313 insertions(+), 178 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 4d0e3dd2e..021949e96 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -209,12 +209,12 @@ private static void AddValidationServices(IServiceCollection services, IConfigur services.AddScoped(); if (configuration.GetSection("AppSettings").Get()?.RequiredValidation == true) { - services.AddTransient(); + services.AddTransient(); } if (configuration.GetSection("AppSettings").Get()?.ExpressionValidation == true) { - services.AddTransient(); + services.AddTransient(); } services.AddTransient(); services.AddTransient(); diff --git a/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs b/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs index e1ad83414..d3d30bfde 100644 --- a/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs +++ b/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs @@ -19,11 +19,13 @@ public interface IProcessExclusiveGateway /// /// Complete list of defined flows out of gateway /// Instance where process is about to move next + /// Cached accessor for instance.Data /// Information connected with the current gateway under evaluation /// List of possible SequenceFlows to choose out of the gateway public Task> FilterAsync( List outgoingFlows, Instance instance, + IInstanceDataAccessor dataAccessor, ProcessGatewayInformation processGatewayInformation ); } diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 17daffdf3..68f613b06 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -23,15 +23,15 @@ public interface IValidator /// /// /// The instance to validate + /// Use this to access data from other data elements /// The current task. /// Language for messages, if the messages are too dynamic for the translation system - /// Use this to access data from other data elements /// public Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ); /// @@ -77,6 +77,11 @@ public class DataElementChange /// public interface IInstanceDataAccessor { + /// + /// The instance that the accessor can access data for. + /// + Instance Instance { get; } + /// /// Get the actual data represented in the data element. /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs index 277a18e1b..3461bd88a 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DataAnnotationValidator.cs @@ -14,7 +14,7 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Runs validation on the data object. /// -public class DataAnnotationValidator : IFormDataValidator +public class DataAnnotationValidator : IFormDataValidator // TODO: This should be IValidator { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IObjectModelValidator _objectModelValidator; diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs index 34c7f34a2..6ef93a865 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultDataElementValidator.cs @@ -7,7 +7,7 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Default validations that run on all data elements to validate metadata and file scan results. /// -public class DefaultDataElementValidator : IDataElementValidator +public class DefaultDataElementValidator : IDataElementValidator //TODO: This should implemnt IValidator { /// /// Run validations on all data elements diff --git a/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs index 0d31dd24e..b0c220daa 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/DefaultTaskValidator.cs @@ -7,7 +7,7 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Implement the default validation of DataElements based on the metadata in appMetadata /// -public class DefaultTaskValidator : ITaskValidator +public class DefaultTaskValidator : ITaskValidator //TODO: Implement IValidator { private readonly IAppMetadata _appMetadata; diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 1f72203c9..e0c091bc7 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -13,7 +13,7 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Validates form data against expression validations /// -public class ExpressionValidator : IFormDataValidator +public class ExpressionValidator : IValidator { private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { ReadCommentHandling = JsonCommentHandling.Skip, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; @@ -21,6 +21,7 @@ public class ExpressionValidator : IFormDataValidator private readonly ILogger _logger; private readonly IAppResources _appResourceService; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + private readonly IAppMetadata _appMetadata; /// /// Constructor for the expression validator @@ -28,16 +29,18 @@ public class ExpressionValidator : IFormDataValidator public ExpressionValidator( ILogger logger, IAppResources appResourceService, - ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, + IAppMetadata appMetadata ) { _logger = logger; _appResourceService = appResourceService; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + _appMetadata = appMetadata; } /// - public string DataType => "*"; + public string TaskId => "*"; /// /// This validator has the code "Expression" and this is known by the frontend, who may request this validator to not run for incremental validation. @@ -47,19 +50,48 @@ ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer /// /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway /// - public bool HasRelevantChanges(object current, object previous) => true; + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) => Task.FromResult(true); /// - public async Task> ValidateFormData( + public async Task> Validate( + Instance instance, + IInstanceDataAccessor instanceDataAccessor, + string taskId, + string? language + ) + { + var dataTypes = (await _appMetadata.GetApplicationMetadata()).DataTypes; + var formDataElementsForTask = instance + .Data.Where(d => + { + var dataType = dataTypes.Find(dt => dt.Id == d.DataType); + return dataType != null && dataType.TaskId == taskId; + }) + .ToList(); + var validationIssues = new List(); + foreach (var dataElement in formDataElementsForTask) + { + var data = instanceDataAccessor.Get(dataElement); + var issues = await ValidateFormData(instance, dataElement, instanceDataAccessor, taskId, language); + validationIssues.AddRange(issues); + } + + return validationIssues; + } + + internal async Task> ValidateFormData( Instance instance, DataElement dataElement, - object data, + IInstanceDataAccessor dataAccessor, + string taskId, string? language ) { - // TODO: Consider not depending on the instance object to get the task - // to follow the same principle as the other validators - var taskId = instance.Process.CurrentTask.ElementId; var rawValidationConfig = _appResourceService.GetValidationConfiguration(dataElement.DataType); if (rawValidationConfig == null) { @@ -71,6 +103,7 @@ public async Task> ValidateFormData( var evaluatorState = await _layoutEvaluatorStateInitializer.Init( instance, + dataAccessor, taskId, gatewayAction: null, language diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index 34617f3cd..034612788 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -52,9 +52,9 @@ public string ValidationSource /// public async Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ) { var issues = new List(); diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs index 5920289ed..07364a595 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs @@ -48,9 +48,9 @@ public string ValidationSource /// public async Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ) { var modelState = new ModelStateDictionary(); diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index ff50289d6..c89ebdaf9 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -7,7 +7,7 @@ namespace Altinn.App.Core.Features.Validation.Default; /// /// Validator that runs the required rules in the layout /// -public class RequiredLayoutValidator : IFormDataValidator +public class RequiredLayoutValidator : IValidator { private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; @@ -20,32 +20,26 @@ public RequiredLayoutValidator(ILayoutEvaluatorStateInitializer layoutEvaluatorS } /// - /// Run for all data types + /// Run for all tasks /// - public string DataType => "*"; + public string TaskId => "*"; /// /// This validator has the code "Required" and this is known by the frontend, who may request this validator to not run for incremental validation. /// public string ValidationSource => ValidationIssueSources.Required; - /// - /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway - /// - public bool HasRelevantChanges(object current, object previous) => true; - /// - public async Task> ValidateFormData( + public async Task> Validate( Instance instance, - DataElement dataElement, - object data, + IInstanceDataAccessor instanceDataAccessor, + string taskId, string? language ) { - var taskId = instance.Process.CurrentTask.ElementId; - var evaluationState = await _layoutEvaluatorStateInitializer.Init( instance, + instanceDataAccessor, taskId, gatewayAction: null, language @@ -53,4 +47,14 @@ public async Task> ValidateFormData( return LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState); } + + /// + /// We don't have an efficient way to figure out if changes to the model results in different validations, and frontend ignores this anyway + /// + public Task HasRelevantChanges( + Instance instance, + string taskId, + List changes, + IInstanceDataAccessor instanceDataAccessor + ) => Task.FromResult(true); } diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs index 090730823..657b73780 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs @@ -34,9 +34,9 @@ List dataTypes /// public async Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ) { var issues = new List(); diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index 2e85bbd25..8022bc3db 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -30,9 +30,9 @@ public FormDataValidatorWrapper(IFormDataValidator formDataValidator, string tas /// public async Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ) { var issues = new List(); diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs index 3b0221443..9d1c944e7 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs @@ -27,9 +27,9 @@ public TaskValidatorWrapper(ITaskValidator taskValidator) /// public Task> Validate( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - string? language, - IInstanceDataAccessor instanceDataAccessor + string? language ) { return _taskValidator.ValidateTask(instance, taskId, language); diff --git a/src/Altinn.App.Core/Helpers/ObjectUtils.cs b/src/Altinn.App.Core/Helpers/ObjectUtils.cs index 8ac0a1a84..a440dc874 100644 --- a/src/Altinn.App.Core/Helpers/ObjectUtils.cs +++ b/src/Altinn.App.Core/Helpers/ObjectUtils.cs @@ -180,8 +180,10 @@ private static void SetToDefaultIfShouldSerializeFalse(object model, PropertyInf /// /// Set all properties named "AltinnRowId" to Guid.Empty /// - public static void RemoveAltinnRowId(object model, int depth = 64) + /// true if any changes to the data has been performed + public static bool RemoveAltinnRowId(object model, int depth = 64) { + var isModified = false; ArgumentNullException.ThrowIfNull(model); if (depth < 0) { @@ -192,7 +194,7 @@ public static void RemoveAltinnRowId(object model, int depth = 64) var type = model.GetType(); if (type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) { - return; // System.DateTime.Now causes infinite recursion, and we shuldn't recurse into system types anyway. + return isModified; // System.DateTime.Now causes infinite recursion, and we shuldn't recurse into system types anyway. } foreach (var prop in type.GetProperties()) @@ -200,6 +202,7 @@ public static void RemoveAltinnRowId(object model, int depth = 64) // Handle guid fields named "AltinnRowId" if (PropertyIsAltinRowGuid(prop)) { + isModified = true; prop.SetValue(model, Guid.Empty); } // Recurse into lists @@ -213,7 +216,7 @@ public static void RemoveAltinnRowId(object model, int depth = 64) // Recurse into values of a list if (item is not null) { - RemoveAltinnRowId(item, depth - 1); + isModified |= RemoveAltinnRowId(item, depth - 1); } } } @@ -226,10 +229,12 @@ public static void RemoveAltinnRowId(object model, int depth = 64) // continue recursion over all properties if (value is not null) { - RemoveAltinnRowId(value, depth - 1); + isModified |= RemoveAltinnRowId(value, depth - 1); } } } + + return isModified; } private static bool PropertyIsAltinRowGuid(PropertyInfo prop) diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index 7680f6bba..2fdd71a39 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -14,6 +14,7 @@ namespace Altinn.App.Core.Internal.Data; /// internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor { + private readonly Instance _instance; private readonly string _org; private readonly string _app; private readonly Guid _instanceGuid; @@ -30,15 +31,20 @@ public CachedInstanceDataAccessor( IAppModel appModel ) { - _org = instance.Org; - _app = instance.AppId.Split("/")[1]; - _instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - _instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + var splitApp = instance.AppId.Split("/"); + _org = splitApp[0]; + _app = splitApp[1]; + var splitId = instance.Id.Split("/"); + _instanceOwnerPartyId = int.Parse(splitId[0], CultureInfo.InvariantCulture); + _instanceGuid = Guid.Parse(splitId[1]); + _instance = instance; _dataClient = dataClient; _appMetadata = appMetadata; _appModel = appModel; } + public Instance Instance => _instance; + /// public async Task Get(DataElement dataElement) { diff --git a/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs index cc1b3f06a..408bdc736 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Internal.Expressions; @@ -14,6 +15,7 @@ public interface ILayoutEvaluatorStateInitializer /// Task Init( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, string? gatewayAction = null, string? language = null diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 2ee87794d..9b8817bee 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -17,19 +17,13 @@ public class LayoutEvaluatorStateInitializer : ILayoutEvaluatorStateInitializer // Dependency injection properties (set in ctor) private readonly IAppResources _appResources; private readonly FrontEndSettings _frontEndSettings; - private readonly IInstanceDataAccessor _dataAccessor; /// /// Constructor with services from dependency injection /// - public LayoutEvaluatorStateInitializer( - IAppResources appResources, - IOptions frontEndSettings, - IInstanceDataAccessor dataAccessor - ) + public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions frontEndSettings) { _appResources = appResources; - _dataAccessor = dataAccessor; _frontEndSettings = frontEndSettings.Value; } @@ -61,6 +55,7 @@ public Task Init( /// public async Task Init( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, string? gatewayAction = null, string? language = null @@ -81,7 +76,7 @@ public async Task Init( dataTasks.AddRange( instance .Data.Where(dataElement => dataElement.DataType == dataType) - .Select(async dataElement => KeyValuePair.Create(dataElement, await _dataAccessor.Get(dataElement))) + .Select(async dataElement => KeyValuePair.Create(dataElement, await dataAccessor.Get(dataElement))) ); } diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 5173eaf56..0909ef11d 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -2,6 +2,9 @@ using System.Text; using System.Text.Json; using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models.Expressions; @@ -28,7 +31,6 @@ public class ExpressionsExclusiveGateway : IProcessExclusiveGateway /// /// Constructor for /// - /// Expressions state initalizer used to create context for expression evaluation public ExpressionsExclusiveGateway(ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) { _layoutStateInit = layoutEvaluatorStateInitializer; @@ -41,11 +43,13 @@ public ExpressionsExclusiveGateway(ILayoutEvaluatorStateInitializer layoutEvalua public async Task> FilterAsync( List outgoingFlows, Instance instance, + IInstanceDataAccessor dataAccessor, ProcessGatewayInformation processGatewayInformation ) { var state = await GetLayoutEvaluatorState( instance, + dataAccessor, instance.Process.CurrentTask.ElementId, processGatewayInformation.Action, language: null @@ -56,12 +60,13 @@ ProcessGatewayInformation processGatewayInformation private async Task GetLayoutEvaluatorState( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, string? gatewayAction, string? language ) { - var state = await _layoutStateInit.Init(instance, taskId, gatewayAction, language); + var state = await _layoutStateInit.Init(instance, dataAccessor, taskId, gatewayAction, language); return state; } diff --git a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs index 73dc6f4c1..9f10465e7 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs @@ -1,4 +1,7 @@ using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; using Altinn.App.Core.Models.Process; @@ -15,22 +18,28 @@ public class ProcessNavigator : IProcessNavigator private readonly IProcessReader _processReader; private readonly ExclusiveGatewayFactory _gatewayFactory; private readonly ILogger _logger; + private readonly IDataClient _dataClient; + private readonly IAppMetadata _appMetadata; + private readonly IAppModel _appModel; /// /// Initialize a new instance of /// - /// The process reader - /// Service to fetch wanted gateway filter implementation - /// The logger public ProcessNavigator( IProcessReader processReader, ExclusiveGatewayFactory gatewayFactory, - ILogger logger + ILogger logger, + IDataClient dataClient, + IAppMetadata appMetadata, + IAppModel appModel ) { _processReader = processReader; _gatewayFactory = gatewayFactory; _logger = logger; + _dataClient = dataClient; + _appMetadata = appMetadata; + _appModel = appModel; } /// @@ -101,8 +110,18 @@ private async Task> NextFollowAndFilterGateways( Action = action, DataTypeId = gateway.ExtensionElements?.GatewayExtension?.ConnectedDataTypeId }; - - filteredList = await gatewayFilter.FilterAsync(outgoingFlows, instance, gatewayInformation); + IInstanceDataAccessor dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _appMetadata, + _appModel + ); + filteredList = await gatewayFilter.FilterAsync( + outgoingFlows, + instance, + dataAccessor, + gatewayInformation + ); } var defaultSequenceFlow = filteredList.Find(s => s.Id == gateway.Default); if (defaultSequenceFlow != null) diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index bd3e0c027..c5baf4b2b 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; @@ -46,72 +47,106 @@ public async Task Finalize(string taskId, Instance instance) ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); List connectedDataTypes = applicationMetadata.DataTypes.FindAll(dt => dt.TaskId == taskId); - await RunRemoveFieldsInModelOnTaskComplete(instance, taskId, connectedDataTypes, language: null); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var changedDataElements = await RunRemoveFieldsInModelOnTaskComplete( + instance, + dataAccessor, + taskId, + connectedDataTypes, + language: null + ); + + // Save changes to the data elements with app logic that was changed. + await Task.WhenAll( + changedDataElements.Select(async dataElement => + { + var data = await dataAccessor.Get(dataElement); + return _dataClient.UpdateData( + data, + Guid.Parse(instance.Id.Split('/')[1]), + data.GetType(), + instance.Org, + instance.AppId.Split('/')[1], + int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture), + Guid.Parse(dataElement.Id) + ); + }) + ); } - private async Task RunRemoveFieldsInModelOnTaskComplete( + private async Task> RunRemoveFieldsInModelOnTaskComplete( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, List dataTypesToLock, string? language = null ) { ArgumentNullException.ThrowIfNull(instance.Data); + HashSet modifiedDataElements = new(); - dataTypesToLock = dataTypesToLock.Where(d => !string.IsNullOrEmpty(d.AppLogic?.ClassRef)).ToList(); + var dataTypesWithLogic = dataTypesToLock.Where(d => !string.IsNullOrEmpty(d.AppLogic?.ClassRef)).ToList(); await Task.WhenAll( instance - .Data.Join(dataTypesToLock, de => de.DataType, dt => dt.Id, (de, dt) => (dataElement: de, dataType: dt)) + .Data.Join( + dataTypesWithLogic, + de => de.DataType, + dt => dt.Id, + (de, dt) => (dataElement: de, dataType: dt) + ) .Select( async (d) => { - await RemoveFieldsOnTaskComplete( - instance, - taskId, - dataTypesToLock, - d.dataElement, - d.dataType, - language - ); + if ( + await RemoveFieldsOnTaskComplete( + instance, + dataAccessor, + taskId, + dataTypesWithLogic, + d.dataElement, + d.dataType, + language + ) + ) + { + modifiedDataElements.Add(d.dataElement); + } } ) ); + return modifiedDataElements; } - private async Task RemoveFieldsOnTaskComplete( + private async Task RemoveFieldsOnTaskComplete( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, - List dataTypesToLock, + List dataTypesWithLogic, DataElement dataElement, DataType dataType, string? language = null ) { - // Download the data - Type modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); - Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - Guid dataGuid = Guid.Parse(dataElement.Id); - string app = instance.AppId.Split("/")[1]; - int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); - object data = await _dataClient.GetFormData( - instanceGuid, - modelType, - instance.Org, - app, - instanceOwnerPartyId, - dataGuid - ); + bool isModified = false; + var data = await dataAccessor.Get(dataElement); + + // remove AltinnRowIds + ObjectUtils.RemoveAltinnRowId(data); + isModified = true; // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) { LayoutEvaluatorState evaluationState = await _layoutEvaluatorStateInitializer.Init( instance, + dataAccessor, taskId, gatewayAction: null, language ); LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.Ignore); + // TODO: + isModified = true; } // Remove shadow fields @@ -121,7 +156,7 @@ private async Task RemoveFieldsOnTaskComplete( if (dataType.AppLogic.ShadowFields.SaveToDataType != null) { // Save the shadow fields to another data type - DataType? saveToDataType = dataTypesToLock.Find(dt => + DataType? saveToDataType = dataTypesWithLogic.Find(dt => dt.Id == dataType.AppLogic.ShadowFields.SaveToDataType ); if (saveToDataType == null) @@ -133,10 +168,14 @@ private async Task RemoveFieldsOnTaskComplete( Type saveToModelType = _appModel.GetModelType(saveToDataType.AppLogic.ClassRef); object? updatedData = JsonSerializer.Deserialize(serializedData, saveToModelType); + // Save a new data element with the cleaned data without shadow fields. + Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + string app = instance.AppId.Split("/")[1]; + int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); await _dataClient.InsertFormData( updatedData, instanceGuid, - saveToModelType ?? modelType, + saveToModelType, instance.Org, app, instanceOwnerPartyId, @@ -147,16 +186,14 @@ await _dataClient.InsertFormData( { // Remove the shadow fields from the data data = - JsonSerializer.Deserialize(serializedData, modelType) + JsonSerializer.Deserialize(serializedData, data.GetType()) ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); } } - // remove AltinnRowIds - ObjectUtils.RemoveAltinnRowId(data); // Save the updated data - await _dataClient.UpdateData(data, instanceGuid, modelType, instance.Org, app, instanceOwnerPartyId, dataGuid); + return isModified; } } diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 33601a4b1..cc3573557 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -54,7 +54,7 @@ public async Task> ValidateInstanceAtTask( using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); try { - var issues = await v.Validate(instance, taskId, language, dataAccessor); + var issues = await v.Validate(instance, dataAccessor, taskId, language); return KeyValuePair.Create( v.ValidationSource, issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) @@ -112,7 +112,7 @@ public async Task>> ValidateI validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorRelevantChanges, hasRelevantChanges); if (hasRelevantChanges) { - var issues = await validator.Validate(instance, taskId, language, dataAccessor); + var issues = await validator.Validate(instance, dataAccessor, taskId, language); var issuesWithSource = issues .Select(i => ValidationIssueWithSource.FromIssue(i, validator.ValidationSource)) .ToList(); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index fe05f569e..38d09ead6 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -1,17 +1,15 @@ -using System.Reflection; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Validation; -using Altinn.App.Core.Tests.Helpers; -using Altinn.App.Core.Tests.LayoutExpressions; using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -21,7 +19,6 @@ using Microsoft.Extensions.Options; using Moq; using Xunit.Abstractions; -using Xunit.Sdk; namespace Altinn.App.Core.Tests.Features.Validators.Default; @@ -33,6 +30,7 @@ public class ExpressionValidatorTests private readonly Mock _appResources = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); private readonly Mock _dataClient = new(MockBehavior.Strict); + private readonly Mock _appModel = new(MockBehavior.Strict); private readonly IOptions _frontendSettings = Microsoft.Extensions.Options.Options.Create( new FrontEndSettings() ); @@ -48,7 +46,12 @@ public ExpressionValidatorTests(ITestOutputHelper output) new ApplicationMetadata("org/app") { DataTypes = new List { new() { Id = "default" } } } ); _appResources.Setup(ar => ar.GetLayoutSetForTask("Task_1")).Returns(new LayoutSet()); - _validator = new ExpressionValidator(_logger.Object, _appResources.Object, _layoutInitializer.Object); + _validator = new ExpressionValidator( + _logger.Object, + _appResources.Object, + _layoutInitializer.Object, + _appMetadata.Object + ); } public ExpressionValidationTestModel LoadData(string fileName, string folder) @@ -75,7 +78,7 @@ private async Task RunExpressionValidationTest(string fileName, string folder) { var testCase = LoadData(fileName, folder); - var instance = new Instance() { Process = new() { CurrentTask = new() { ElementId = "Task_1", } } }; + var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app", }; var dataElement = new DataElement { DataType = "default", }; var dataModel = DynamicClassBuilder.DataModelFromJsonDocument(testCase.FormData, dataElement); @@ -83,7 +86,13 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var evaluatorState = new LayoutEvaluatorState(dataModel, testCase.Layouts, _frontendSettings.Value, instance); _layoutInitializer .Setup(init => - init.Init(It.Is(i => i == instance), "Task_1", It.IsAny(), It.IsAny()) + init.Init( + It.Is(i => i == instance), + It.IsAny(), + "Task_1", + It.IsAny(), + It.IsAny() + ) ) .ReturnsAsync(evaluatorState); _appResources @@ -91,7 +100,14 @@ private async Task RunExpressionValidationTest(string fileName, string folder) .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); _appResources.Setup(ar => ar.GetLayoutSetForTask(null!)).Returns(new LayoutSet() { DataType = "default", }); - var validationIssues = await _validator.ValidateFormData(instance, dataElement, null!, null); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient.Object, + _appMetadata.Object, + _appModel.Object + ); + + var validationIssues = await _validator.ValidateFormData(instance, dataElement, dataAccessor, "Task_1", null); var result = validationIssues.Select(i => new { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs index 00f3268a7..3c7005ef7 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -75,7 +75,7 @@ public async Task ValidateFormData_WithErrors() _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data); // Act - var result = await _validator.Validate(_instance, "Task_1", null, _instanceDataAccessor.Object); + var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); // Assert result @@ -154,7 +154,7 @@ public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); // Act - var result = await _validator.Validate(_instance, "Task_1", null, _instanceDataAccessor.Object); + var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); // Assert result.Should().HaveCount(2); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index fdb010c18..4338e857b 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -105,8 +105,6 @@ private class MyModel private readonly Mock _formDataValidatorAlwaysMock = new(MockBehavior.Strict) { Name = "alwaysFormDataValidator" }; - private readonly Mock _httpContextAccessorMock = new(); - private readonly ServiceCollection _serviceCollection = new(); public ValidationServiceTests() @@ -126,9 +124,6 @@ public ValidationServiceTests() _appMetadataMock.Setup(a => a.GetApplicationMetadata()).ReturnsAsync(_defaultAppMetadata); _serviceCollection.AddSingleton(); - _httpContextAccessorMock.Setup(h => h.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); - _serviceCollection.AddSingleton(_httpContextAccessorMock.Object); - _serviceCollection.AddSingleton(Microsoft.Extensions.Options.Options.Create(new GeneralSettings())); // NeverUsedValidators diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 087d51184..464eef5c1 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -92,19 +92,7 @@ public PatchServiceTests() [], _appMetadataMock.Object ); - var validationService = new ValidationService( - validatorFactory, - _dataClientMock.Object, - _appModelMock.Object, - _appMetadataMock.Object, - _vLoggerMock.Object, - new CachedFormDataAccessor( - _dataClientMock.Object, - _appMetadataMock.Object, - _appModelMock.Object, - _httpContextAccessorMock.Object - ) - ); + var validationService = new ValidationService(validatorFactory, _appMetadataMock.Object, _vLoggerMock.Object); _patchService = new PatchService( _appMetadataMock.Object, diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 1ec884a46..7fda8b907 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -14,7 +14,6 @@ using Altinn.App.Core.Models.Process; using Altinn.App.Core.Tests.Internal.Process.TestData; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Moq; @@ -29,7 +28,6 @@ public class ExpressionsExclusiveGatewayTests private readonly Mock _appModel = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); private readonly Mock _dataClient = new(MockBehavior.Strict); - private readonly Mock _httpContextAccessor = new(MockBehavior.Strict); private const string Org = "ttd"; private const string App = "test"; @@ -57,7 +55,6 @@ public async Task FilterAsync_NoExpressions_ReturnsAllFlows() var data = new DummyModel(); - var gateway = SetupExpressionsGateway(dataTypes: dataTypes, formData: data); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = null, }, @@ -76,8 +73,10 @@ public async Task FilterAsync_NoExpressions_ReturnsAllFlows() }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; + var (gateway, dataAccessor) = SetupExpressionsGateway(instance, dataTypes: dataTypes, formData: data); + // Act - var result = await gateway.FilterAsync(outgoingFlows, instance, processGatewayInformation); + var result = await gateway.FilterAsync(outgoingFlows, instance, dataAccessor, processGatewayInformation); // Assert Assert.Equal(2, result.Count); @@ -99,7 +98,6 @@ public async Task FilterAsync_Expression_filters_based_on_action() }; var data = new DummyModel(); - var gateway = SetupExpressionsGateway(dataTypes: dataTypes, formData: data); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"equals\", [\"gatewayAction\"], \"confirm\"]", }, @@ -118,8 +116,10 @@ public async Task FilterAsync_Expression_filters_based_on_action() }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; + var (gateway, dataAccessor) = SetupExpressionsGateway(instance, dataTypes, formData: data); + // Act - var result = await gateway.FilterAsync(outgoingFlows, instance, processGatewayInformation); + var result = await gateway.FilterAsync(outgoingFlows, instance, dataAccessor, processGatewayInformation); // Assert Assert.Single(result); @@ -156,11 +156,6 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou } } }; - var gateway = SetupExpressionsGateway( - dataTypes: dataTypes, - formData: formData, - layoutSets: LayoutSetsToString(layoutSets) - ); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"notEquals\", [\"dataModel\", \"Amount\"], 1000]", }, @@ -179,8 +174,15 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; + var (gateway, dataAccessor) = SetupExpressionsGateway( + instance, + dataTypes: dataTypes, + formData: formData, + layoutSets: LayoutSetsToString(layoutSets) + ); + // Act - var result = await gateway.FilterAsync(outgoingFlows, instance, processGatewayInformation); + var result = await gateway.FilterAsync(outgoingFlows, instance, dataAccessor, processGatewayInformation); // Assert Assert.Single(result); @@ -218,11 +220,6 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew } } }; - var gateway = SetupExpressionsGateway( - dataTypes: dataTypes, - formData: formData, - layoutSets: LayoutSetsToString(layoutSets) - ); var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"notEquals\", [\"dataModel\", \"Amount\"], 1000]", }, @@ -241,15 +238,23 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", DataTypeId = "aa" }; + var (gateway, dataAccessor) = SetupExpressionsGateway( + instance, + dataTypes, + LayoutSetsToString(layoutSets), + formData + ); + // Act - var result = await gateway.FilterAsync(outgoingFlows, instance, processGatewayInformation); + var result = await gateway.FilterAsync(outgoingFlows, instance, dataAccessor, processGatewayInformation); // Assert Assert.Single(result); Assert.Equal("2", result[0].Id); } - private ExpressionsExclusiveGateway SetupExpressionsGateway( + private (ExpressionsExclusiveGateway gateway, IInstanceDataAccessor dataAccessor) SetupExpressionsGateway( + Instance instance, List dataTypes, string? layoutSets = null, object? formData = null @@ -300,18 +305,15 @@ private ExpressionsExclusiveGateway SetupExpressionsGateway( var frontendSettings = Options.Create(new FrontEndSettings()); - var layoutStateInit = new LayoutEvaluatorStateInitializer( - _resources.Object, - frontendSettings, - new CachedInstanceDataAccessor( - _instance, - _dataClient.Object, - _appMetadata.Object, - _appModel.Object, - _httpContextAccessor.Object - ) + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient.Object, + _appMetadata.Object, + _appModel.Object ); - return new ExpressionsExclusiveGateway(layoutStateInit); + + var layoutStateInit = new LayoutEvaluatorStateInitializer(_resources.Object, frontendSettings); + return (new ExpressionsExclusiveGateway(layoutStateInit), dataAccessor); } private static string LayoutSetsToString(LayoutSets layoutSets) => diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index 634e8149b..9f6ead5a4 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -1,5 +1,7 @@ -#nullable disable using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; @@ -8,11 +10,16 @@ using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using Moq; namespace Altinn.App.Core.Tests.Internal.Process; public class ProcessNavigatorTests { + private readonly Mock _dataClient = new(MockBehavior.Strict); + private readonly Mock _appMetadata = new(MockBehavior.Strict); + private readonly Mock _appModel = new(MockBehavior.Strict); + [Fact] public async Task GetNextTask_returns_next_element_if_no_gateway() { @@ -20,7 +27,7 @@ public async Task GetNextTask_returns_next_element_if_no_gateway() "simple-linear.bpmn", new List() ); - ProcessElement nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); nextElements .Should() .BeEquivalentTo( @@ -45,7 +52,7 @@ public async Task NextFollowAndFilterGateways_returns_empty_list_if_no_outgoing_ "simple-linear.bpmn", new List() ); - ProcessElement nextElements = await processNavigator.GetNextTask(new Instance(), "EndEvent", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "EndEvent", null); nextElements.Should().BeNull(); } @@ -56,7 +63,7 @@ public async Task GetNextTask_returns_default_if_no_filtering_is_implemented_and "simple-gateway-default.bpmn", new List() ); - ProcessElement nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); nextElements .Should() .BeEquivalentTo( @@ -85,9 +92,14 @@ public async Task GetNextTask_runs_custom_filter_and_returns_result() "simple-gateway-with-join-gateway.bpmn", new List() { new DataValuesFilter("Gateway1", "choose") } ); - Instance i = new Instance() { DataValues = new Dictionary() { { "choose", "Flow3" } } }; + Instance i = new Instance() + { + Id = $"123/{Guid.NewGuid()}", + AppId = "org/app", + DataValues = new Dictionary() { { "choose", "Flow3" } } + }; - ProcessElement nextElements = await processNavigator.GetNextTask(i, "Task1", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(i, "Task1", null); nextElements .Should() .BeEquivalentTo( @@ -133,8 +145,13 @@ public async Task GetNextTask_follows_downstream_gateways() "simple-gateway-with-join-gateway.bpmn", new List() { new DataValuesFilter("Gateway1", "choose1") } ); - Instance i = new Instance() { DataValues = new Dictionary() { { "choose1", "Flow4" } } }; - ProcessElement nextElements = await processNavigator.GetNextTask(i, "Task1", null); + Instance i = new Instance() + { + Id = $"123/{Guid.NewGuid()}", + AppId = "org/app", + DataValues = new Dictionary() { { "choose1", "Flow4" } } + }; + ProcessElement? nextElements = await processNavigator.GetNextTask(i, "Task1", null); nextElements .Should() .BeEquivalentTo( @@ -161,10 +178,12 @@ public async Task GetNextTask_runs_custom_filter_and_returns_empty_list_if_all_f ); Instance i = new Instance() { + Id = $"123/{Guid.NewGuid()}", + AppId = "org/app", DataValues = new Dictionary() { { "choose1", "Flow4" }, { "choose2", "Bar" } } }; - ProcessElement nextElements = await processNavigator.GetNextTask(i, "Task1", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(i, "Task1", null); nextElements.Should().BeNull(); } @@ -175,13 +194,13 @@ public async Task GetNextTask_returns_empty_list_if_element_has_no_next() "simple-gateway-with-join-gateway.bpmn", new List() ); - Instance i = new Instance(); + Instance i = new Instance() { Id = $"123/{Guid.NewGuid()}", AppId = "org/app", }; - ProcessElement nextElements = await processNavigator.GetNextTask(i, "EndEvent", null); + ProcessElement? nextElements = await processNavigator.GetNextTask(i, "EndEvent", null); nextElements.Should().BeNull(); } - private static IProcessNavigator SetupProcessNavigator( + private IProcessNavigator SetupProcessNavigator( string bpmnfile, IEnumerable gatewayFilters ) @@ -190,7 +209,10 @@ IEnumerable gatewayFilters return new ProcessNavigator( pr, new ExclusiveGatewayFactory(gatewayFilters), - new NullLogger() + new NullLogger(), + _dataClient.Object, + _appMetadata.Object, + _appModel.Object ); } } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs b/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs index 8ad5c7d2f..89f34745d 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs @@ -20,6 +20,7 @@ public DataValuesFilter(string gatewayId, string filterOnDataValue) public async Task> FilterAsync( List outgoingFlows, Instance instance, + IInstanceDataAccessor dataAccessor, ProcessGatewayInformation processGatewayInformation ) { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 633358164..750be0051 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -93,11 +93,6 @@ public static async Task GetLayoutModelTools(object model, services.AddSingleton(appMetadata.Object); services.AddSingleton(appModel.Object); services.AddScoped(); - services.AddScoped(); - - var httpContextAccessorMock = new Mock(); - httpContextAccessorMock.SetupGet(c => c.HttpContext!.TraceIdentifier).Returns(Guid.NewGuid().ToString()); - services.AddSingleton(httpContextAccessorMock.Object); services.AddOptions().Configure(fes => fes.Add("test", "value")); @@ -105,6 +100,9 @@ public static async Task GetLayoutModelTools(object model, using var scope = serviceProvider.CreateScope(); var initializer = scope.ServiceProvider.GetRequiredService(); - return await initializer.Init(_instance, TaskId); + var dataAccessor = new CachedInstanceDataAccessor(_instance, data.Object, appMetadata.Object, appModel.Object); + dataAccessor.Set(_instance.Data[0], model); + + return await initializer.Init(_instance, dataAccessor, TaskId); } } From e6cda48f6cb05849a46f02202396fea22059226c Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 19 Aug 2024 21:55:40 +0200 Subject: [PATCH 09/63] Fix sonar cloud issues --- src/Altinn.App.Api/Controllers/ActionsController.cs | 8 ++++---- src/Altinn.App.Api/Controllers/DataController.cs | 7 ------- .../Controllers/ValidateController.cs | 9 ++++----- src/Altinn.App.Core/Features/ITaskValidator.cs | 1 - src/Altinn.App.Core/Features/IValidator.cs | 2 +- .../Validation/Default/ExpressionValidator.cs | 1 - .../LegacyIInstanceValidatorFormDataValidator.cs | 2 +- .../Validation/Wrappers/FormDataValidatorWrapper.cs | 6 ++---- src/Altinn.App.Core/Helpers/DataModel/DataModel.cs | 7 ++++++- .../Internal/Data/CachedFormDataAccessor.cs | 4 +--- .../Expressions/LayoutEvaluatorStateInitializer.cs | 5 +++-- src/Altinn.App.Core/Internal/Patch/PatchService.cs | 3 +-- .../Internal/Process/ExpressionsExclusiveGateway.cs | 4 ---- .../ProcessTasks/Common/ProcessTaskFinalizer.cs | 13 +++++++------ .../Internal/Validation/IValidatorFactory.cs | 2 +- .../Internal/Validation/ValidationService.cs | 10 ++-------- .../Models/Validation/ValidationIssue.cs | 1 - .../Default/LegacyIValidationFormDataTests.cs | 4 ++-- .../Internal/Patch/PatchServiceTests.cs | 2 +- 19 files changed, 36 insertions(+), 55 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index a1eeaed01..6c6f288af 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -202,7 +202,7 @@ Dictionary resultUpdatedDataModels continue; } var dataElement = instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); - var previousData = await dataAccessor.Get(dataElement); + var previousData = await dataAccessor.GetData(dataElement); ObjectUtils.InitializeAltinnRowId(newModel); ObjectUtils.PrepareModelForXmlStorage(newModel); @@ -254,7 +254,7 @@ await _dataClient.UpdateData( return PartitionValidationIssuesByDataElement(validationIssues); } - private Dictionary< + private static Dictionary< string, Dictionary> > PartitionValidationIssuesByDataElement(Dictionary> validationIssues) @@ -266,12 +266,12 @@ private Dictionary< { if (!updatedValidationIssues.TryGetValue(issue.DataElementId ?? "", out var elementIssues)) { - elementIssues = new Dictionary>(); + elementIssues = []; updatedValidationIssues[issue.DataElementId ?? ""] = elementIssues; } if (!elementIssues.TryGetValue(validationSource, out var sourceIssues)) { - sourceIssues = new List(); + sourceIssues = []; elementIssues[validationSource] = sourceIssues; } sourceIssues.Add(issue); diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 02a009c21..400f796c9 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -511,13 +511,6 @@ public async Task> PatchFormDataMultiple ); } - CachedInstanceDataAccessor dataAccessor = new CachedInstanceDataAccessor( - instance, - _dataClient, - _appMetadata, - _appModel - ); - foreach (Guid dataGuid in dataPatchRequest.Patches.Keys) { var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index e8f231ff5..0238f6293 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -130,7 +130,7 @@ public async Task ValidateData( throw new ValidationException("Unable to validate instance without a started process."); } - List messages = new List(); + List messages = []; DataElement? element = instance.Data.FirstOrDefault(d => d.Id == dataGuid.ToString()); @@ -150,10 +150,9 @@ public async Task ValidateData( var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); - // TODO: Consider filtering so that only relevant issues are reported. - messages.AddRange( - await _validationService.ValidateInstanceAtTask(instance, dataType.TaskId, dataAccessor, language) - ); + // Run validations for all data elements, but only return the issues for the specific data element + var issues = await _validationService.ValidateInstanceAtTask(instance, dataType.TaskId, dataAccessor, language); + messages.AddRange(issues.Where(i => i.DataElementId == element.Id)); string taskId = instance.Process.CurrentTask.ElementId; diff --git a/src/Altinn.App.Core/Features/ITaskValidator.cs b/src/Altinn.App.Core/Features/ITaskValidator.cs index 1cf2c6abf..c84355e84 100644 --- a/src/Altinn.App.Core/Features/ITaskValidator.cs +++ b/src/Altinn.App.Core/Features/ITaskValidator.cs @@ -1,6 +1,5 @@ using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.Extensions.DependencyInjection; namespace Altinn.App.Core.Features; diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 68f613b06..829980f69 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -87,5 +87,5 @@ public interface IInstanceDataAccessor /// /// The data element to retrieve. Must be from the instance that is currently active /// The deserialized data model for this data element or a stream for binary elements - Task Get(DataElement dataElement); + Task GetData(DataElement dataElement); } diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index e0c091bc7..07e75401d 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -76,7 +76,6 @@ public async Task> Validate( var validationIssues = new List(); foreach (var dataElement in formDataElementsForTask) { - var data = instanceDataAccessor.Get(dataElement); var issues = await ValidateFormData(instance, dataElement, instanceDataAccessor, taskId, language); validationIssues.AddRange(issues); } diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index 034612788..bcea696b6 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -62,7 +62,7 @@ public async Task> Validate( var dataTypes = appMetadata.DataTypes.Where(d => d.TaskId == taskId).Select(d => d.Id).ToList(); foreach (var dataElement in instance.Data.Where(d => dataTypes.Contains(d.DataType))) { - var data = await instanceDataAccessor.Get(dataElement); + var data = await instanceDataAccessor.GetData(dataElement); var modelState = new ModelStateDictionary(); await _instanceValidator.ValidateData(data, modelState); issues.AddRange( diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index 8022bc3db..4ad4b3a08 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -10,13 +10,11 @@ internal class FormDataValidatorWrapper : IValidator { private readonly IFormDataValidator _formDataValidator; private readonly string _taskId; - private readonly List _dataTypes; - public FormDataValidatorWrapper(IFormDataValidator formDataValidator, string taskId, List dataTypes) + public FormDataValidatorWrapper(IFormDataValidator formDataValidator, string taskId) { _formDataValidator = formDataValidator; _taskId = taskId; - _dataTypes = dataTypes; } /// @@ -44,7 +42,7 @@ public async Task> Validate( continue; } - var data = await instanceDataAccessor.Get(dataElement); + var data = await instanceDataAccessor.GetData(dataElement); var dataElementValidationResult = await _formDataValidator.ValidateFormData( instance, dataElement, diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 06a171ae9..a8db9a747 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -84,7 +84,12 @@ public DataModel(IEnumerable> dataModels) return null; } - private object? GetModelDataRecursive(string[] keys, int index, object? currentModel, ReadOnlySpan indicies) + private static object? GetModelDataRecursive( + string[] keys, + int index, + object? currentModel, + ReadOnlySpan indicies + ) { if (index == keys.Length || currentModel is null) { diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index 2fdd71a39..448adc7b0 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -46,7 +46,7 @@ IAppModel appModel public Instance Instance => _instance; /// - public async Task Get(DataElement dataElement) + public async Task GetData(DataElement dataElement) { return await _cache.GetOrCreate( dataElement.Id, @@ -97,7 +97,6 @@ public async Task GetOrCreate(TKey key, Func> valueFa { task = _cache.GetOrAdd(key, innerKey => new Lazy>(() => valueFactory(innerKey))).Value; } - ; return await task; } @@ -116,7 +115,6 @@ public void Set(TKey key, TValue data) private async Task GetBinaryData(DataElement dataElement) { - ; var data = await _dataClient.GetBinaryData( _org, _app, diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 9b8817bee..b95cba265 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -3,7 +3,6 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.Data; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -76,7 +75,9 @@ public async Task Init( dataTasks.AddRange( instance .Data.Where(dataElement => dataElement.DataType == dataType) - .Select(async dataElement => KeyValuePair.Create(dataElement, await dataAccessor.Get(dataElement))) + .Select(async dataElement => + KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement)) + ) ); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index 1e01ed0bd..f48f2d0b2 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -11,7 +11,6 @@ using Altinn.App.Core.Models.Result; using Altinn.Platform.Storage.Interface.Models; using Json.Patch; -using static Altinn.App.Core.Features.Telemetry; namespace Altinn.App.Core.Internal.Patch; @@ -78,7 +77,7 @@ public async Task> ApplyPatches( }; } - var oldModel = await dataAccessor.Get(dataElement); + var oldModel = await dataAccessor.GetData(dataElement); var oldModelNode = JsonSerializer.SerializeToNode(oldModel); var patchResult = jsonPatch.Apply(oldModelNode); diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 0909ef11d..a8282326b 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -1,10 +1,6 @@ -using System.Globalization; using System.Text; using System.Text.Json; using Altinn.App.Core.Features; -using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; -using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models.Expressions; diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index c5baf4b2b..0ce5a8cf7 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -60,7 +60,7 @@ public async Task Finalize(string taskId, Instance instance) await Task.WhenAll( changedDataElements.Select(async dataElement => { - var data = await dataAccessor.Get(dataElement); + var data = await dataAccessor.GetData(dataElement); return _dataClient.UpdateData( data, Guid.Parse(instance.Id.Split('/')[1]), @@ -83,7 +83,7 @@ private async Task> RunRemoveFieldsInModelOnTaskComplet ) { ArgumentNullException.ThrowIfNull(instance.Data); - HashSet modifiedDataElements = new(); + HashSet modifiedDataElements = []; var dataTypesWithLogic = dataTypesToLock.Where(d => !string.IsNullOrEmpty(d.AppLogic?.ClassRef)).ToList(); await Task.WhenAll( @@ -128,11 +128,10 @@ private async Task RemoveFieldsOnTaskComplete( ) { bool isModified = false; - var data = await dataAccessor.Get(dataElement); + var data = await dataAccessor.GetData(dataElement); // remove AltinnRowIds - ObjectUtils.RemoveAltinnRowId(data); - isModified = true; + isModified |= ObjectUtils.RemoveAltinnRowId(data); // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) @@ -145,7 +144,7 @@ private async Task RemoveFieldsOnTaskComplete( language ); LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.Ignore); - // TODO: + // TODO: Make RemoveHiddenData return a bool indicating if data was removed isModified = true; } @@ -185,11 +184,13 @@ await _dataClient.InsertFormData( else { // Remove the shadow fields from the data + // TODO: This does not work!!! data = JsonSerializer.Deserialize(serializedData, data.GetType()) ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); + isModified = true; // TODO: Detect if modifications were made } } diff --git a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs index c469b7b12..fda064a36 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs @@ -131,7 +131,7 @@ public IEnumerable GetValidators(string taskId) .Select(dev => new DataElementValidatorWrapper(dev, taskId, dataTypes)) ); validators.AddRange( - GetFormDataValidators(taskId, dataTypes).Select(fdv => new FormDataValidatorWrapper(fdv, taskId, dataTypes)) + GetFormDataValidators(taskId, dataTypes).Select(fdv => new FormDataValidatorWrapper(fdv, taskId)) ); // add legacy instance validators wrapped in IValidator wrappers diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index cc3573557..911f81046 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -1,7 +1,4 @@ using Altinn.App.Core.Features; -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.Logging; @@ -14,7 +11,6 @@ namespace Altinn.App.Core.Internal.Validation; public class ValidationService : IValidationService { private readonly IValidatorFactory _validatorFactory; - private readonly IAppMetadata _appMetadata; private readonly ILogger _logger; private readonly Telemetry? _telemetry; @@ -23,13 +19,11 @@ public class ValidationService : IValidationService /// public ValidationService( IValidatorFactory validatorFactory, - IAppMetadata appMetadata, ILogger logger, Telemetry? telemetry = null ) { _validatorFactory = validatorFactory; - _appMetadata = appMetadata; _logger = logger; _telemetry = telemetry; } @@ -64,7 +58,7 @@ public async Task> ValidateInstanceAtTask( { _logger.LogError( e, - "Error while running validator {validatorName} for task {taskId} on instance {instanceId}", + "Error while running validator {ValidatorName} for task {TaskId} on instance {InstanceId}", v.ValidationSource, taskId, instance.Id @@ -144,7 +138,7 @@ public async Task>> ValidateI return lists.Where(k => k.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value!); } - private void ThrowIfDuplicateValidators(IValidator[] validators, string taskId) + private static void ThrowIfDuplicateValidators(IValidator[] validators, string taskId) { var sourceNames = validators .Select(v => v.ValidationSource) diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs index 130d959d7..dd2fde834 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssue.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Altinn.App.Core.Internal.Validation; using Newtonsoft.Json; namespace Altinn.App.Core.Models.Validation; diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs index 3c7005ef7..a0b954875 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -72,7 +72,7 @@ public async Task ValidateFormData_WithErrors() ) .Verifiable(Times.Once); - _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data); + _instanceDataAccessor.Setup(ida => ida.GetData(_dataElement)).ReturnsAsync(data); // Act var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); @@ -151,7 +151,7 @@ public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string } ) .Verifiable(Times.Once); - _instanceDataAccessor.Setup(ida => ida.Get(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); + _instanceDataAccessor.Setup(ida => ida.GetData(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); // Act var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 464eef5c1..86fd8b348 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -92,7 +92,7 @@ public PatchServiceTests() [], _appMetadataMock.Object ); - var validationService = new ValidationService(validatorFactory, _appMetadataMock.Object, _vLoggerMock.Object); + var validationService = new ValidationService(validatorFactory, _vLoggerMock.Object); _patchService = new PatchService( _appMetadataMock.Object, From 3c4ac0c213ae74f33aa32e436aee3828fb621b26 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 20 Aug 2024 21:54:08 +0200 Subject: [PATCH 10/63] Add tests for shadow fields and hidden component data removal in process/next --- .../Common/ProcessTaskFinalizer.cs | 6 +- .../Controllers/ProcessControllerTests.cs | 212 ++++++++++++++++-- .../CustomWebApplicationFactory.cs | 15 +- .../contributer-restriction/models/Skjema.cs | 13 ++ .../ui/layouts/page.json | 10 +- .../Internal/Process/ProcessNavigatorTests.cs | 2 +- 6 files changed, 238 insertions(+), 20 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 0ce5a8cf7..635e6088f 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -136,6 +136,9 @@ private async Task RemoveFieldsOnTaskComplete( // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) { + // Backend removal of data is deprecated in favor of + // implementing frontend removal of hidden data, so + //this is not updated to remove from multiple data models at once. LayoutEvaluatorState evaluationState = await _layoutEvaluatorStateInitializer.Init( instance, dataAccessor, @@ -184,12 +187,13 @@ await _dataClient.InsertFormData( else { // Remove the shadow fields from the data - // TODO: This does not work!!! + data = JsonSerializer.Deserialize(serializedData, data.GetType()) ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); + (dataAccessor as CachedInstanceDataAccessor)?.Set(dataElement, data); isModified = true; // TODO: Detect if modifications were made } } diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs index 870092dc5..567d1b9d6 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs @@ -2,14 +2,20 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Api.Models; using Altinn.App.Api.Tests.Data; +using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Pdf; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; +using App.IntegrationTests.Mocks.Services; using FluentAssertions; +using Json.Patch; +using Json.Pointer; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -23,9 +29,9 @@ public class ProcessControllerTests : ApiTestBase, IClassFixture factory, ITestOutpu services.AddSingleton(_dataProcessorMock.Object); services.AddSingleton(_formDataValidatorMock.Object); }; - TestData.DeleteInstanceAndData(Org, App, InstanceOwnerPartyId, InstanceGuid); - TestData.PrepareInstance(Org, App, InstanceOwnerPartyId, InstanceGuid); + TestData.DeleteInstanceAndData(Org, App, InstanceOwnerPartyId, _instanceGuid); + TestData.PrepareInstance(Org, App, InstanceOwnerPartyId, _instanceGuid); } [Fact] @@ -146,7 +152,7 @@ public async Task RunProcessNextWithLang_VerifyPdfCallWithLanguage() using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); // both "?lang" and "?language" should work var nextResponse = await client.PutAsync( - $"{Org}/{App}/instances/{InstanceId}/process/next?lang={language}", + $"{Org}/{App}/instances/{_instanceId}/process/next?lang={language}", null ); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); @@ -179,7 +185,7 @@ public async Task RunProcessNextWithLanguage_VerifyPdfCall() using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); // both "?lang" and "?language" should work var nextResponse = await client.PutAsync( - $"{Org}/{App}/instances/{InstanceId}/process/next?language={language}", + $"{Org}/{App}/instances/{_instanceId}/process/next?language={language}", null ); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); @@ -191,7 +197,7 @@ public async Task RunProcessNextWithLanguage_VerifyPdfCall() public async Task RunProcessNext_PdfFails_DataIsUnlocked() { bool sendAsyncCalled = false; - var dataElementPath = TestData.GetDataElementPath(Org, App, InstanceOwnerPartyId, InstanceGuid, DataGuid); + var dataElementPath = TestData.GetDataElementPath(Org, App, InstanceOwnerPartyId, _instanceGuid, _dataGuid); SendAsync = async message => { @@ -213,7 +219,7 @@ public async Task RunProcessNext_PdfFails_DataIsUnlocked() return new HttpResponseMessage(HttpStatusCode.TooManyRequests); }; using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); - var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{InstanceId}/process/next", null); + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", null); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(nextResponseContent); nextResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); @@ -256,7 +262,7 @@ public async Task RunProcessNext_FailingValidator_ReturnsValidationErrors() services.AddSingleton(dataValidator.Object); }; using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); - var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{InstanceId}/process/next", null); + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", null); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(nextResponseContent); nextResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); @@ -270,11 +276,185 @@ public async Task RunProcessNext_FailingValidator_ReturnsValidationErrors() ); // Verify that the instance is not updated - var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, InstanceGuid); + var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, _instanceGuid); instance.Process.CurrentTask.Should().NotBeNull(); instance.Process.CurrentTask!.ElementId.Should().Be("Task_1"); } + [Fact] + public async Task RunProcessNext_DataFromHiddenComponents_GetsRemoved() + { + // Override config to remove hidden data + OverrideAppSetting("AppSettings:RemoveHiddenData", "true"); + + // Mock pdf generation so that the test does not fail due to pof service not running. + var pdfMock = new Mock(MockBehavior.Strict); + using var pdfReturnStream = new MemoryStream(); + pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + OverrideServicesForThisTest = (services) => + { + services.AddSingleton(pdfMock.Object); + }; + // setup data processor + _dataProcessorMock + .Setup(dp => + dp.ProcessDataWrite( + It.IsAny(), + _dataGuid, + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + // create client for tests + using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); + var dataPath = TestData.GetDataBlobPath(Org, App, InstanceOwnerPartyId, _instanceGuid, _dataGuid); + + // Update hidden data value + var serializedPatch = JsonSerializer.Serialize( + new DataPatchRequest() + { + Patch = new JsonPatch( + PatchOperation.Add( + JsonPointer.Create("melding", "hidden"), + JsonNode.Parse("\"value that is hidden\"") + ) + ), + IgnoredValidators = [] + }, + _jsonSerializerOptions + ); + _outputHelper.WriteLine(serializedPatch); + using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); + using var response = await client.PatchAsync( + $"{Org}/{App}/instances/{InstanceOwnerPartyId}/{_instanceGuid}/data/{_dataGuid}", + updateDataElementContent + ); + response.Should().HaveStatusCode(HttpStatusCode.OK); + + // Verify that hidden is stored + var dataString = await File.ReadAllTextAsync(dataPath); + _outputHelper.WriteLine("Data before process next:"); + _outputHelper.WriteLine(dataString); + dataString.Should().Contain("value that is hidden"); + + // Run process next + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", null); + var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); + _outputHelper.WriteLine(nextResponseContent); + nextResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // Verify that the instance is updated to the ended state + dataString = await File.ReadAllTextAsync(dataPath); + _outputHelper.WriteLine("Data after process next:"); + _outputHelper.WriteLine(dataString); + dataString.Should().NotContain("value that is hidden"); + + _dataProcessorMock.Verify(); + } + + [Theory] + [InlineData(null)] + [InlineData("copyDataType")] + public async Task RunProcessNext_ShadowFields_GetsRemoved(string? saveToDataType) + { + // Mock pdf generation so that the test does not fail due to pof service not running. + var pdfMock = new Mock(MockBehavior.Strict); + using var pdfReturnStream = new MemoryStream(); + pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + OverrideServicesForThisTest = (services) => + { + services.AddSingleton(pdfMock.Object); + services.AddSingleton( + new AppMetadataMutationHook(appMetadata => + { + var defaultDataType = appMetadata.DataTypes.Single(dt => dt.Id == "default"); + defaultDataType.AppLogic.ShadowFields = new() { Prefix = "SF_", SaveToDataType = saveToDataType }; + + if (saveToDataType is not null) + appMetadata.DataTypes.Add( + new DataType() + { + Id = saveToDataType, + TaskId = "Task_1", + AppLogic = new() { ClassRef = defaultDataType.AppLogic.ClassRef } + } + ); + }) + ); + }; + // setup data processor + _dataProcessorMock + .Setup(dp => + dp.ProcessDataWrite( + It.IsAny(), + _dataGuid, + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Once); + + // create client for tests + using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); + + // Update hidden data value + var serializedPatch = JsonSerializer.Serialize( + new DataPatchRequest() + { + Patch = new JsonPatch( + PatchOperation.Add( + JsonPointer.Create("melding", "SF_test"), + JsonNode.Parse("\"value that is in shadow field\"") + ) + ), + IgnoredValidators = [] + }, + _jsonSerializerOptions + ); + _outputHelper.WriteLine(serializedPatch); + using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); + using var response = await client.PatchAsync( + $"{Org}/{App}/instances/{InstanceOwnerPartyId}/{_instanceGuid}/data/{_dataGuid}", + updateDataElementContent + ); + response.Should().HaveStatusCode(HttpStatusCode.OK); + + // Verify that hidden is stored + var dataPath = TestData.GetDataBlobPath(Org, App, InstanceOwnerPartyId, _instanceGuid, _dataGuid); + var dataString = await File.ReadAllTextAsync(dataPath); + _outputHelper.WriteLine("Data before process next:"); + _outputHelper.WriteLine(dataString); + dataString.Should().Contain("value that is in shadow field"); + + // Run process next + using var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", null); + var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); + _outputHelper.WriteLine(nextResponseContent); + nextResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + // Get data path if the data element with shadow fields removed is saved to another data type + if (saveToDataType is not null) + { + var instanceClient = Services.GetRequiredService(); + var instance = await instanceClient.GetInstance(App, Org, InstanceOwnerPartyId, _instanceGuid); + var copyDataGuid = Guid.Parse(instance.Data.Single(de => de.DataType == saveToDataType).Id); + dataPath = TestData.GetDataBlobPath(Org, App, InstanceOwnerPartyId, _instanceGuid, copyDataGuid); + } + // Verify that the instance is updated to the ended state + dataString = await File.ReadAllTextAsync(dataPath); + _outputHelper.WriteLine("Data after process next:"); + _outputHelper.WriteLine(dataString); + dataString.Should().NotContain("value that is in shadow field"); + + _dataProcessorMock.Verify(); + } + [Fact] public async Task RunProcessNext_NonErrorValidations_ReturnsOk() { @@ -328,7 +508,7 @@ public async Task RunProcessNext_NonErrorValidations_ReturnsOk() services.AddSingleton(pdfMock.Object); }; using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); - var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{InstanceId}/process/next", null); + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", null); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(nextResponseContent); nextResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -336,7 +516,7 @@ public async Task RunProcessNext_NonErrorValidations_ReturnsOk() document.RootElement.EnumerateObject().Should().NotContain(p => p.Name == "validationIssues"); // Verify that the instance is updated to the ended state - var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, InstanceGuid); + var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, _instanceGuid); instance.Process.CurrentTask.Should().BeNull(); instance.Process.EndEvent.Should().Be("EndEvent_1"); } @@ -352,13 +532,13 @@ public async Task RunCompleteTask_GoesToEndEvent() services.AddSingleton(pdfMock.Object); }; using var client = GetRootedClient(Org, App, 1337, InstanceOwnerPartyId); - var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{InstanceId}/process/completeProcess", null); + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/completeProcess", null); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(nextResponseContent); nextResponse.Should().HaveStatusCode(HttpStatusCode.OK); // Verify that the instance is updated to the ended state - var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, InstanceGuid); + var instance = await TestData.GetInstance(Org, App, InstanceOwnerPartyId, _instanceGuid); instance.Process.CurrentTask.Should().BeNull(); instance.Process.EndEvent.Should().Be("EndEvent_1"); } @@ -379,7 +559,7 @@ public async Task RunNextWithAction_WhenActionIsNotAuthorized_ReturnsUnauthorize Encoding.UTF8, "application/json" ); - var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{InstanceId}/process/next", content); + var nextResponse = await client.PutAsync($"{Org}/{App}/instances/{_instanceId}/process/next", content); var nextResponseContent = await nextResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(nextResponseContent); nextResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); diff --git a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs index b1435f869..c2d64af46 100644 --- a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs +++ b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs @@ -58,7 +58,10 @@ public HttpClient GetRootedClient(string org, string app, bool includeTraceConte var factory = _factory.WithWebHostBuilder(builder => { - var configuration = new ConfigurationBuilder().AddJsonFile(appSettingsPath).Build(); + var configuration = new ConfigurationBuilder() + .AddJsonFile(appSettingsPath) + .AddInMemoryCollection(_configOverrides) + .Build(); configuration.GetSection("AppSettings:AppBasePath").Value = appRootPath; IConfigurationSection appSettingSection = configuration.GetSection("AppSettings"); @@ -79,6 +82,16 @@ public HttpClient GetRootedClient(string org, string app, bool includeTraceConte return client; } + /// + /// Overrides the app settings for the test application. + /// + public void OverrideAppSetting(string key, string? value) + { + _configOverrides[key] = value; + } + + private readonly Dictionary _configOverrides = new(); + private sealed class DiagnosticHandler : DelegatingHandler { protected override Task SendAsync( diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/models/Skjema.cs b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/models/Skjema.cs index 5d299395b..9a10f272b 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/models/Skjema.cs +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/models/Skjema.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE1006 // Naming Styles does not matter in model classes using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using System.Xml.Serialization; @@ -55,6 +56,16 @@ public bool ShouldSerializeTagWithAttribute() { return TagWithAttribute?.value != null; } + + [XmlElement("hidden", Order = 8)] + [JsonProperty("hidden")] + [JsonPropertyName("hidden")] + public string? Hidden { get; set; } + + [XmlElement("SF_test", Order = 9)] + [JsonProperty("SF_test")] + [JsonPropertyName("SF_test")] + public string? SF_test { get; set; } } public class TagWithAttribute @@ -129,3 +140,5 @@ public bool AltinnRowIdSpecified() [JsonPropertyName("values")] public List? Values { get; set; } } + +#pragma warning restore IDE1006 // Naming Styles diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json index ca66ac17f..4d7d98ae9 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json @@ -17,7 +17,15 @@ "dataModelBindings": { "simpleBinding": "melding.name" } + }, + { + "id": "hidden", + "type": "Input", + "hidden": true, + "dataModelBindings": { + "simpleBinding": "melding.hidden" + } } ] } -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index 9f6ead5a4..74b947fe9 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -200,7 +200,7 @@ public async Task GetNextTask_returns_empty_list_if_element_has_no_next() nextElements.Should().BeNull(); } - private IProcessNavigator SetupProcessNavigator( + private ProcessNavigator SetupProcessNavigator( string bpmnfile, IEnumerable gatewayFilters ) From c27f6275baf93b33af24d4662386d44c080fab67 Mon Sep 17 00:00:00 2001 From: HauklandJ Date: Wed, 21 Aug 2024 10:04:56 +0200 Subject: [PATCH 11/63] chore: update to new storage nuget --- src/Altinn.App.Api/Altinn.App.Api.csproj | 6 +----- src/Altinn.App.Core/Altinn.App.Core.csproj | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Altinn.App.Api/Altinn.App.Api.csproj b/src/Altinn.App.Api/Altinn.App.Api.csproj index f0ef0cef7..5c2dd2718 100644 --- a/src/Altinn.App.Api/Altinn.App.Api.csproj +++ b/src/Altinn.App.Api/Altinn.App.Api.csproj @@ -16,11 +16,7 @@ - - - - - + diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index 3a58621b4..1a2812f93 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -16,11 +16,7 @@ - - - - - + From 79a918bc2379677359eefa26b5e911541606aadb Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 22 Aug 2024 11:59:04 +0200 Subject: [PATCH 12/63] Improve testing for multiple patch endpoint --- .../Controllers/DataController.cs | 32 +++- .../LayoutEvaluatorStateInitializer.cs | 23 ++- .../Controllers/DataController_PatchTests.cs | 143 +++++++++++++++++- 3 files changed, 178 insertions(+), 20 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 400f796c9..e64c2d45e 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -477,7 +477,7 @@ public async Task> PatchFormData( } /// - /// Updates an existing form data element with a patch of changes. + /// Updates an existing form data element with patches to mulitple data elements. /// /// unique identfier of the organisation responsible for the app /// application identifier which is unique within an organisation @@ -507,17 +507,31 @@ public async Task> PatchFormDataMultiple if (!InstanceIsActive(instance)) { return Conflict( - $"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}" + new ProblemDetails() + { + Title = "Instance is not active", + Detail = + $"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.Conflict, + } ); } foreach (Guid dataGuid in dataPatchRequest.Patches.Keys) { - var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); + var dataElement = instance.Data.Find(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); - if (dataElement == null) + if (dataElement is null) { - return NotFound("Did not find data element"); + return NotFound( + new ProblemDetails() + { + Title = "Did not find data element", + Detail = + $"Data element with id {dataGuid} not found on instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.NotFound, + } + ); } var dataType = await GetDataType(dataElement); @@ -530,7 +544,13 @@ public async Task> PatchFormDataMultiple org, app ); - return BadRequest($"Could not determine if data type {dataType?.Id} requires application logic."); + return BadRequest( + new ProblemDetails() + { + Title = "Could not determine if data type requires application logic", + Detail = $"Could not determine if data type {dataType?.Id} requires application logic." + } + ); } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index b95cba265..5aadc89f8 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -72,13 +72,22 @@ public async Task Init( var dataTasks = new List>>(); foreach (var dataType in layouts.GetReferencedDataTypeIds()) { - dataTasks.AddRange( - instance - .Data.Where(dataElement => dataElement.DataType == dataType) - .Select(async dataElement => - KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement)) - ) - ); + // Find first data element of type dataType + var dataElement = instance.Data.Find(d => d.DataType == dataType); + if (dataElement is not null) + { + dataTasks.Add( + Task.Run(async () => KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement))) + ); + } + // TODO: This will change when subforms use the same data type for multiple data elemetns. + // dataTasks.AddRange( + // instance + // .Data.Where(dataElement => dataElement.DataType == dataType) + // .Select(async dataElement => + // KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement)) + // ) + // ); } var extraModels = await Task.WhenAll(dataTasks); diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 7b5d68306..68d2e6687 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -12,6 +12,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; +using App.IntegrationTests.Mocks.Services; using FluentAssertions; using Json.More; using Json.Patch; @@ -48,6 +49,10 @@ public class DataControllerPatchTests : ApiTestBase, IClassFixture _dataProcessorMock = new(MockBehavior.Strict); private readonly Mock _formDataValidatorMock = new(MockBehavior.Strict); + private HttpClient? _client; + + private HttpClient GetClient() => _client ??= GetRootedClient(Org, App, UserId, null); + // Constructor with common setup public DataControllerPatchTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) : base(factory, outputHelper) @@ -81,14 +86,44 @@ TResponse parsedResponse url += $"?language={language}"; } _outputHelper.WriteLine($"Calling PATCH {url}"); - using var httpClient = GetRootedClient(Org, App, UserId, null); + var serializedPatch = JsonSerializer.Serialize( new DataPatchRequest() { Patch = patch, IgnoredValidators = ignoredValidators, }, _jsonSerializerOptions ); _outputHelper.WriteLine(serializedPatch); using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); - var response = await httpClient.PatchAsync(url, updateDataElementContent); + var response = await GetClient().PatchAsync(url, updateDataElementContent); + var responseString = await response.Content.ReadAsStringAsync(); + using var responseParsedRaw = JsonDocument.Parse(responseString); + _outputHelper.WriteLine("\nResponse:"); + _outputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, _jsonSerializerOptions)); + response.Should().HaveStatusCode(expectedStatus); + var responseObject = JsonSerializer.Deserialize(responseString, _jsonSerializerOptions)!; + return (response, responseString, responseObject); + } + + // Helper method to call the API + private async Task<( + HttpResponseMessage response, + string responseString, + TResponse parsedResponse + )> CallPatchMultipleApi( + DataPatchRequestMultiple requestMultiple, + HttpStatusCode expectedStatus, + string? language = null + ) + { + var url = $"/{Org}/{App}/instances/{InstanceId}/data"; + if (language is not null) + { + url += $"?language={language}"; + } + _outputHelper.WriteLine($"Calling PATCH {url}"); + var serializedPatch = JsonSerializer.Serialize(requestMultiple, _jsonSerializerOptions); + _outputHelper.WriteLine(serializedPatch); + using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); + var response = await GetClient().PatchAsync(url, updateDataElementContent); var responseString = await response.Content.ReadAsStringAsync(); using var responseParsedRaw = JsonDocument.Parse(responseString); _outputHelper.WriteLine("\nResponse:"); @@ -143,6 +178,95 @@ public async Task ValidName_ReturnsOk() _dataProcessorMock.VerifyNoOtherCalls(); } + [Fact] + public async Task MultiplePatches_AppliesCorrectly() + { + const string prefillDataType = "prefill-data-type"; + OverrideServicesForThisTest = (services) => + { + services.AddSingleton( + new AppMetadataMutationHook( + (app) => + { + app.DataTypes.Add( + new DataType() + { + Id = prefillDataType, + AllowedContentTypes = new List { "application/json" }, + AppLogic = new() + { + ClassRef = + "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", + }, + } + ); + } + ) + ); + }; + _dataProcessorMock + .Setup(p => + p.ProcessDataWrite( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + null + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(2)); + + // Initialize extra data element + var createExtraElementResponse = await GetClient() + .PostAsync( + $"{Org}/{App}/instances/{InstanceId}/data?dataType={prefillDataType}", + new StringContent("""{"melding":{}}""", Encoding.UTF8, "application/json") + ); + var createExtraElementResponseString = await createExtraElementResponse.Content.ReadAsStringAsync(); + _outputHelper.WriteLine(createExtraElementResponseString); + createExtraElementResponse.Should().HaveStatusCode(HttpStatusCode.Created); + var extraDataId = JsonSerializer + .Deserialize(createExtraElementResponseString, _jsonSerializerOptions) + ?.Id; + extraDataId.Should().NotBeNull(); + var extraDataGuid = Guid.Parse(extraDataId!); + + // Update data element + var patch = new JsonPatch( + PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("\"Ola Olsen\"")) + ); + var patch2 = new JsonPatch( + PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("\"Kari Olsen\"")) + ); + var request = new DataPatchRequestMultiple() + { + Patches = new Dictionary { [DataGuid] = patch, [extraDataGuid] = patch2, }, + IgnoredValidators = [] + }; + + var (_, _, parsedResponse) = await CallPatchMultipleApi(request, HttpStatusCode.OK); + + parsedResponse.ValidationIssues.Should().ContainKey("Required").WhoseValue.Should().BeEmpty(); + + parsedResponse.NewDataModels.Should().HaveCount(2).And.ContainKey(DataGuid).And.ContainKey(extraDataGuid); + var newData = parsedResponse + .NewDataModels[DataGuid] + .Should() + .BeOfType() + .Which.Deserialize()!; + newData.Melding!.Name.Should().Be("Ola Olsen"); + + var newExtraData = parsedResponse + .NewDataModels[extraDataGuid] + .Should() + .BeOfType() + .Which.Deserialize()!; + newExtraData.Melding!.Name.Should().Be("Kari Olsen"); + + _dataProcessorMock.Verify(); + } + [Fact] public async Task NullName_ReturnsOkAndValidationError() { @@ -486,7 +610,7 @@ public async Task RemoveStringProperty_ReturnsCorrectDataModel() } [Fact] - public async Task SetStringPropertyToEmtpy_ReturnsCorrectDataModel() + public async Task SetStringPropertyToEmpty_ReturnsCorrectDataModel() { _dataProcessorMock .Setup(p => @@ -531,7 +655,7 @@ public async Task SetStringPropertyToEmtpy_ReturnsCorrectDataModel() } [Fact] - public async Task SetAttributeTagPropertyToEmtpy_ReturnsCorrectDataModel() + public async Task SetAttributeTagPropertyToEmpty_ReturnsCorrectDataModel() { _dataProcessorMock .Setup(p => @@ -578,7 +702,7 @@ public async Task SetAttributeTagPropertyToEmtpy_ReturnsCorrectDataModel() public async Task RowId_GetsAddedAutomatically() { var rowIdServer = Guid.NewGuid(); - var rowIdClinet = Guid.NewGuid(); + var rowIdClient = Guid.NewGuid(); _dataProcessorMock .Setup(p => p.ProcessDataWrite( @@ -621,7 +745,7 @@ public async Task RowId_GetsAddedAutomatically() { "key": "KeyFromClient", "intValue": 123, - "altinnRowId": "{{rowIdClinet}}" + "altinnRowId": "{{rowIdClient}}" }, { "key": "KeyFromClientNoRowId", @@ -647,7 +771,7 @@ public async Task RowId_GetsAddedAutomatically() { Key = "KeyFromClient", IntValue = 123, - AltinnRowId = rowIdClinet + AltinnRowId = rowIdClient }, new() { @@ -862,4 +986,9 @@ public async Task IgnoredValidators_NotExecuted() _dataProcessorMock.Verify(); _formDataValidatorMock.Verify(); } + + ~DataControllerPatchTests() + { + _client?.Dispose(); + } } From e23d1d2a1cc48d8d290ace43cffa37fc7d2098dd Mon Sep 17 00:00:00 2001 From: HauklandJ Date: Wed, 28 Aug 2024 12:02:09 +0200 Subject: [PATCH 13/63] update Storage.Interface package reference to 3.33.0 --- src/Altinn.App.Api/Altinn.App.Api.csproj | 2 +- src/Altinn.App.Core/Altinn.App.Core.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Api/Altinn.App.Api.csproj b/src/Altinn.App.Api/Altinn.App.Api.csproj index 5c2dd2718..2074df97b 100644 --- a/src/Altinn.App.Api/Altinn.App.Api.csproj +++ b/src/Altinn.App.Api/Altinn.App.Api.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index 1a2812f93..1616576ef 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -16,7 +16,7 @@ - + From af1200b5a14f8bd2ed139b72766ed78936bef888 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Thu, 29 Aug 2024 15:06:05 +0200 Subject: [PATCH 14/63] Fixes some tests that were double-creating the `default` data element --- .../Controllers/DataController_PutTests.cs | 38 ++----------------- .../UserDefinedMetadataControllerTests.cs | 21 ++-------- .../config/applicationmetadata.json | 2 +- 3 files changed, 7 insertions(+), 54 deletions(-) diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs index b73c67957..2cb8a2459 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs @@ -43,23 +43,7 @@ public async Task PutDataElement_TestSinglePartUpdate_ReturnsOk() ); var createResponseParsed = await VerifyStatusAndDeserialize(createResponse, HttpStatusCode.Created); var instanceId = createResponseParsed.Id; - - // Create data element (not sure why it isn't created when the instance is created, autoCreate is true) - using var createDataElementContent = new StringContent( - """{"melding":{"name": "Ivar"}}""", - System.Text.Encoding.UTF8, - "application/json" - ); - var createDataElementResponse = await client.PostAsync( - $"/{org}/{app}/instances/{instanceId}/data?dataType=default", - createDataElementContent - ); - - var createDataElementResponseParsed = await VerifyStatusAndDeserialize( - createDataElementResponse, - HttpStatusCode.Created - ); - var dataGuid = createDataElementResponseParsed.Id; + var dataGuid = createResponseParsed.Data.First(x => x.DataType.Equals("default")).Id; // Update data element using var updateDataElementContent = new StringContent( @@ -148,22 +132,7 @@ public async Task PutDataElement_TestMultiPartUpdateWithCustomDataProcessor_Retu ); var createResponseParsed = await VerifyStatusAndDeserialize(createResponse, HttpStatusCode.Created); var instanceId = createResponseParsed.Id; - - // Create data element (not sure why it isn't created when the instance is created, autoCreate is true) - using var createDataElementContent = new StringContent( - """{"melding":{"name": "Ivar"}}""", - System.Text.Encoding.UTF8, - "application/json" - ); - var createDataElementResponse = await client.PostAsync( - $"/{org}/{app}/instances/{instanceId}/data?dataType=default", - createDataElementContent - ); - var createDataElementResponseParsed = await VerifyStatusAndDeserialize( - createDataElementResponse, - HttpStatusCode.Created - )!; - var dataGuid = createDataElementResponseParsed.Id; + var dataGuid = createResponseParsed.Data.First(x => x.DataType.Equals("default")).Id; // Verify stored data var firstReadDataElementResponse = await client.GetAsync( @@ -173,8 +142,7 @@ public async Task PutDataElement_TestMultiPartUpdateWithCustomDataProcessor_Retu firstReadDataElementResponse, HttpStatusCode.OK ); - firstReadDataElementResponseParsed.Melding!.Name.Should().Be("Ivar"); - firstReadDataElementResponseParsed.Melding.Toggle.Should().BeFalse(); + firstReadDataElementResponseParsed.Melding.Should().BeNull(); // Update data element using var updateDataElementContent = new StringContent( diff --git a/test/Altinn.App.Api.Tests/Controllers/UserDefinedMetadataControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/UserDefinedMetadataControllerTests.cs index 49be712b8..55f753e21 100644 --- a/test/Altinn.App.Api.Tests/Controllers/UserDefinedMetadataControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/UserDefinedMetadataControllerTests.cs @@ -147,26 +147,11 @@ public async Task PutUserDefinedMetadata_InvalidDataElementId_ReturnsNotFound() ); var createdInstance = await VerifyStatusAndDeserialize(createResponse, HttpStatusCode.Created); - string? instanceId = createdInstance.Id; - - // Create data element (not sure why it isn't created when the instance is created, autoCreate is true) - using var createDataElementContent = new StringContent( - """{"melding":{"name": "Ola Normann"}}""", - System.Text.Encoding.UTF8, - "application/json" - ); - - HttpResponseMessage createDataElementResponse = await client.PostAsync( - $"/{Org}/{App}/instances/{instanceId}/data?dataType=default", - createDataElementContent - ); - var createDataElementResponseParsed = await VerifyStatusAndDeserialize( - createDataElementResponse, - HttpStatusCode.Created - ); + // DataElement is created automatically by ProcessTaskInitializer since autoCreate=true + string dataGuid = createdInstance.Data.First(x => x.DataType.Equals("default")).Id; + string instanceId = createdInstance.Id; - string? dataGuid = createDataElementResponseParsed.Id; return (instanceId, dataGuid); } diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json index 1d154ffa4..71b8bcc9c 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json @@ -18,7 +18,7 @@ "allowedContentTypes": [ "application/xml" ], - "maxCount": 2, + "maxCount": 1, "appLogic": { "autoCreate": true, "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema" From 07c1af7d72baa01b000a5b4fbf7b89279715f6e6 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Thu, 29 Aug 2024 15:12:20 +0200 Subject: [PATCH 15/63] Reorders DataController->MaxCount logic, adds missing ClassRef check before CreateAppModelData call --- .../Controllers/DataController.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 2f5a4837f..0f7a33886 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -162,24 +162,25 @@ [FromQuery] string dataType ); } + int existingElements = instance.Data.Count(d => d.DataType == dataTypeFromMetadata.Id); + if (dataTypeFromMetadata.MaxCount > 0 && existingElements >= dataTypeFromMetadata.MaxCount) + { + return Conflict( + $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})" + ); + } + if (dataTypeFromMetadata.AppLogic is not null) { - // TODO: How does this affect other implementations?? - // TODO: Is the `urn:altinn:org` claim how we identify instance creation by service owners/automation, as opposed to users? if (!dataTypeFromMetadata.AppLogic.AllowUserCreate && !UserHasValidOrgClaim()) { return BadRequest($"Element type `{dataType}` cannot be manually created."); } - int existingElements = instance.Data.Count(d => d.DataType == dataTypeFromMetadata.Id); - if (dataTypeFromMetadata.MaxCount > 0 && existingElements >= dataTypeFromMetadata.MaxCount) + if (dataTypeFromMetadata.AppLogic.ClassRef is not null) { - return Conflict( - $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})" - ); + return await CreateAppModelData(org, app, instance, dataType); } - - return await CreateAppModelData(org, app, instance, dataType); } (bool validationRestrictionSuccess, List errors) = From 300faacb0f63dfa3908a318e04eb7c0701b3a3b5 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 30 Aug 2024 12:23:55 +0200 Subject: [PATCH 16/63] More changes to make tests work after merge Make expression engine async --- .../Models/UserActionResponse.cs | 4 + .../Features/IInstanceDataAccessor.cs | 26 + src/Altinn.App.Core/Features/IValidator.cs | 18 - .../InterfaceFactory/InterfaceFactory.cs | 49 ++ .../Features/Telemetry.InterfaceFactory.cs | 11 + .../Validation/Default/ExpressionValidator.cs | 15 +- .../Validation/Default/RequiredValidator.cs | 2 +- .../Helpers/DataModel/DataModel.cs | 490 +++--------------- .../Helpers/DataModel/DataModelWrapper.cs | 460 ++++++++++++++++ .../Internal/Data/CachedFormDataAccessor.cs | 65 ++- .../Expressions/ExpressionEvaluator.cs | 53 +- .../Internal/Expressions/LayoutEvaluator.cs | 90 ++-- .../Expressions/LayoutEvaluatorState.cs | 322 ++++++------ .../LayoutEvaluatorStateInitializer.cs | 87 ++-- .../Process/ExpressionsExclusiveGateway.cs | 47 +- .../Common/ProcessTaskFinalizer.cs | 2 +- src/Altinn.App.Core/Models/DataElementId.cs | 23 + .../Models/Expressions/ComponentContext.cs | 35 +- .../Models/Layout/DataReference.cs | 17 + .../Models/Layout/LayoutModel.cs | 80 +-- .../Default/ExpressionValidatorTests.cs | 18 +- .../ExpressionsExclusiveGatewayTests.cs | 47 +- .../CommonTests/ContextListRoot.cs | 3 +- .../CommonTests/ExpressionTestCaseRoot.cs | 16 +- .../LayoutModelConverterFromObject.cs | 21 +- .../TestBackendExclusiveFunctions.cs | 36 +- .../CommonTests/TestContextList.cs | 37 +- .../CommonTests/TestFunctions.cs | 95 ++-- .../CommonTests/TestInvalid.cs | 31 +- .../functions/dataModel/array-is-null.json | 2 +- .../dataModel/direct-reference-in-group.json | 2 +- .../direct-reference-in-nested-group.json | 2 +- .../direct-reference-in-nested-group2.json | 2 +- .../direct-reference-in-nested-group3.json | 2 +- .../direct-reference-in-nested-group4.json | 2 +- .../functions/dataModel/in-group.json | 2 +- .../functions/dataModel/in-nested-group.json | 2 +- .../functions/dataModel/null-is-null.json | 2 +- .../functions/dataModel/null.json | 2 +- .../functions/dataModel/object-is-null.json | 2 +- .../dataModel/simple-lookup-equals.json | 2 +- .../dataModel/simple-lookup-is-null.json | 2 +- .../dataModel/simple-lookup-is-null2.json | 2 +- .../functions/dataModel/simple-lookup.json | 2 +- .../component-lookup-non-default-model.json | 4 +- .../component-lookup-non-existant-model.json | 4 +- .../dataModel-non-default-model.json | 4 +- .../dataModel-non-existing-model.json | 4 +- .../FullTests/LayoutTestUtils.cs | 16 +- .../FullTests/Test1/RunTest1.cs | 20 +- .../FullTests/Test2/RunTest2.cs | 20 +- .../FullTests/Test3/RunTest3.cs | 22 +- .../LayoutExpressions/TestDataModel.cs | 91 ++-- .../TestUtilities/DynamicClassBuilder.cs | 30 +- .../TestUtilities/TestInstanceDataAccessor.cs | 55 ++ .../TestUtils/ServiceProviderTests.cs | 48 ++ test/Suggestions for DI scope issue.md | 28 + 57 files changed, 1518 insertions(+), 1058 deletions(-) create mode 100644 src/Altinn.App.Core/Features/IInstanceDataAccessor.cs create mode 100644 src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs create mode 100644 src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs create mode 100644 src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs create mode 100644 src/Altinn.App.Core/Models/DataElementId.cs create mode 100644 src/Altinn.App.Core/Models/Layout/DataReference.cs create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs create mode 100644 test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs create mode 100644 test/Suggestions for DI scope issue.md diff --git a/src/Altinn.App.Api/Models/UserActionResponse.cs b/src/Altinn.App.Api/Models/UserActionResponse.cs index a03224fd3..b8fa4a0c9 100644 --- a/src/Altinn.App.Api/Models/UserActionResponse.cs +++ b/src/Altinn.App.Api/Models/UserActionResponse.cs @@ -19,6 +19,10 @@ public class UserActionResponse /// Gets a dictionary of updated validation issues. The first key is the data model id, the second key is the validator id /// Validators that are not listed in the dictionary are assumed to have not been executed /// + /// + /// The validation logic has changed, so the extra separation on data element is kept only for backwards compatibility + /// To implement correct incremental validation, you must concatenate issues for all data elements. + /// [JsonPropertyName("updatedValidationIssues")] public Dictionary< string, diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs new file mode 100644 index 000000000..e5757f69f --- /dev/null +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -0,0 +1,26 @@ +using Altinn.App.Core.Models; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Features; + +/// +/// Service for accessing data from other data elements in the +/// +public interface IInstanceDataAccessor +{ + /// + /// The instance that the accessor can access data for. + /// + Instance Instance { get; } + + /// + /// Get the actual data represented in the data element. + /// + /// The deserialized data model for this data element or a stream for binary elements + Task GetData(DataElementId dataElementId); + + /// + /// Get actual data represented, when there should only be a single element of this type. + /// + Task GetSingleDataByType(string dataType); +} diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 829980f69..279e895d6 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -71,21 +71,3 @@ public class DataElementChange /// public required object CurrentValue { get; init; } } - -/// -/// Service for accessing data from other data elements in the -/// -public interface IInstanceDataAccessor -{ - /// - /// The instance that the accessor can access data for. - /// - Instance Instance { get; } - - /// - /// Get the actual data represented in the data element. - /// - /// The data element to retrieve. Must be from the instance that is currently active - /// The deserialized data model for this data element or a stream for binary elements - Task GetData(DataElement dataElement); -} diff --git a/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs b/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs new file mode 100644 index 000000000..17b2b538c --- /dev/null +++ b/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices.Marshalling; +using Altinn.App.Core.Internal.Process.ProcessTasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.InterfaceFactory; + +internal class InterfaceFactory +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly Telemetry _telemetry; + + private IServiceProvider _serviceProvider => + _httpContextAccessor.HttpContext?.RequestServices + ?? throw new InvalidOperationException("RequestServices is null"); + + public InterfaceFactory(IHttpContextAccessor httpContextAccessor, Telemetry telemetry) + { + _httpContextAccessor = httpContextAccessor; + _telemetry = telemetry; + } + + private T[] GetServices() + where T : notnull + { + var stopwatch = new System.Diagnostics.Stopwatch(); + stopwatch.Start(); + + // call .ToArray to ensure that the services are fully + // resolved before stopping the stopwatch. + var services = _serviceProvider.GetServices().ToArray(); + + stopwatch.Stop(); + var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; + if (elapsedMilliseconds > 1) + { + var message = $"Resolved {services.Length} services of type {typeof(T).Name} in {elapsedMilliseconds} ms"; + var serviceNames = services.Select(s => s.GetType().Name).ToArray(); + //TODO: add telemetry span for this service initialization. + } + return services; + } + + public IDataProcessor[] GetDataProcessors() => GetServices(); + + public IInstantiationProcessor[] GetInstantiationProcessors() => GetServices(); + + public IProcessTaskInitializer[] GetProcessTaskInitializers() => GetServices(); +} diff --git a/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs b/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs new file mode 100644 index 000000000..cf79b41c8 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; + +namespace Altinn.App.Core.Features; + +public partial class Telemetry +{ + internal Activity? GetUserDefinedService(string name) + { + return ActivitySource.StartActivity($"GetUserDefinedService{name}"); + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 07e75401d..55cb02739 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -107,14 +107,14 @@ internal async Task> ValidateFormData( gatewayAction: null, language ); - var hiddenFields = LayoutEvaluator.GetHiddenFieldsForRemoval(evaluatorState, true); + var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval(evaluatorState, true); var validationIssues = new List(); var expressionValidations = ParseExpressionValidationConfig(validationConfig.RootElement, _logger); foreach (var validationObject in expressionValidations) { - var baseField = new ModelBinding { Field = validationObject.Key, DataType = dataElement.DataType }; - var resolvedFields = evaluatorState.GetResolvedKeys(baseField); + var baseField = new DataReference() { Field = validationObject.Key, DataElementId = dataElement }; + var resolvedFields = await evaluatorState.GetResolvedKeys(baseField); var validations = validationObject.Value; foreach (var resolvedField in resolvedFields) { @@ -124,8 +124,9 @@ internal async Task> ValidateFormData( } var context = new ComponentContext( component: null, - rowIndices: DataModel.GetRowIndices(resolvedField), - rowLength: null + rowIndices: DataModel.GetRowIndices(resolvedField.Field), + rowLength: null, + dataElementId: resolvedField.DataElementId ); var positionalArguments = new object[] { resolvedField }; foreach (var validation in validations) @@ -137,7 +138,7 @@ internal async Task> ValidateFormData( continue; } - var validationResult = ExpressionEvaluator.EvaluateExpression( + var validationResult = await ExpressionEvaluator.EvaluateExpression( evaluatorState, validation.Condition.Value, context, @@ -149,7 +150,7 @@ internal async Task> ValidateFormData( var validationIssue = new ValidationIssue { Field = resolvedField.Field, - DataElementId = resolvedField.DataType, + DataElementId = resolvedField.DataElementId.Id.ToString(), Severity = validation.Severity ?? ValidationIssueSeverity.Error, CustomTextKey = validation.Message, Code = validation.Message, diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index c89ebdaf9..1dcf45162 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -45,7 +45,7 @@ public async Task> Validate( language ); - return LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState); + return await LayoutEvaluator.RunLayoutValidationsForRequired(evaluationState); } /// diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index a8db9a747..617e91129 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -1,11 +1,9 @@ using System.Collections; -using System.Diagnostics; -using System.Globalization; -using System.Reflection; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; -using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Helpers.DataModel; @@ -14,46 +12,40 @@ namespace Altinn.App.Core.Helpers.DataModel; /// public class DataModel { - private readonly object _defaultServiceModel; - private readonly Dictionary _dataModels = []; + private readonly IInstanceDataAccessor _dataAccessor; + private readonly Dictionary _dataIdsByType = new(); /// /// Constructor that wraps a POCO data model, and gives extra tool for working with the data /// - public DataModel(IEnumerable> dataModels) + public DataModel(IInstanceDataAccessor dataAccessor) { - var count = 0; - foreach (var (dataElement, data) in dataModels) + _dataAccessor = dataAccessor; + foreach (var dataElement in dataAccessor.Instance.Data) { - if (count++ == 0) - { - DefaultDataElement = dataElement; - _defaultServiceModel = data; - } - _dataModels.Add(dataElement.DataType, data); + // TODO: only add data elements with maxCount == 1 + // for now only add the first one as this requires reading appMetadata + _dataIdsByType.TryAdd(dataElement.DataType, dataElement); } - Debug.Assert(DefaultDataElement is not null, "DataModel initialized with no data elements"); - Debug.Assert(_defaultServiceModel is not null, "DataModel initialized with no data"); } - private object? ServiceModel(ModelBinding key) + private async Task ServiceModel(ModelBinding key, DataElementId defaultDataElementId) { if (key.DataType == null) { - return _defaultServiceModel; + return await _dataAccessor.GetData(defaultDataElementId); } - if (_dataModels.TryGetValue(key.DataType, out var dataModel)) + if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) { - Debug.Assert(dataModel is not null); - return dataModel; + return await _dataAccessor.GetData(dataElementId); } - return null; + throw new InvalidOperationException("Data model with type " + key.DataType + " not found"); } /// - /// Get model data based on key and optionally indicies + /// Get model data based on key and optionally indexes /// /// /// Inline indicies in the key "Bedrifter[1].Ansatte[1].Alder" will override @@ -61,82 +53,21 @@ public DataModel(IEnumerable> dataModels) /// "Bedrifter[1].Ansatte.Alder", will fail, because the indicies will be reset /// after an inline index is used /// - public object? GetModelData(ModelBinding key, ReadOnlySpan indicies = default) + public async Task GetModelData(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) { - return GetModelDataRecursive(key.Field.Split('.'), 0, ServiceModel(key), indicies); + var model = await ServiceModel(key, defaultDataElementId); + var modelWrapper = new DataModelWrapper(model); + return modelWrapper.GetModelData(key.Field, rowIndexes); } /// /// Get the count of data elements set in a group (enumerable) /// - public int? GetModelDataCount(ModelBinding key, ReadOnlySpan indicies = default) + public async Task GetModelDataCount(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) { - if (GetModelDataRecursive(key.Field.Split('.'), 0, ServiceModel(key), indicies) is IEnumerable childEnum) - { - int retCount = 0; - foreach (var _ in childEnum) - { - retCount++; - } - return retCount; - } - - return null; - } - - private static object? GetModelDataRecursive( - string[] keys, - int index, - object? currentModel, - ReadOnlySpan indicies - ) - { - if (index == keys.Length || currentModel is null) - { - return currentModel; - } - - var (key, groupIndex) = ParseKeyPart(keys[index]); - var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); - var childModel = prop?.GetValue(currentModel); - if (childModel is null) - { - return null; - } - - // Strings are enumerable in C# - // Other enumerable types is treated as a collection - if (!(childModel is not string && childModel is IEnumerable childModelList)) - { - return GetModelDataRecursive(keys, index + 1, childModel, indicies); - } - - if (groupIndex is null) - { - if (index == keys.Length - 1) - { - return childModelList; - } - - if (indicies.Length == 0) - { - return null; // Error index for collection not specified - } - - groupIndex = indicies[0]; - } - else - { - indicies = default; //when you use a literal index, the context indecies are not to be used later. - } - - var elementAt = GetElementAt(childModelList, groupIndex.Value); - if (elementAt is null) - { - return null; // Error condition, no value at index - } - - return GetModelDataRecursive(keys, index + 1, elementAt, indicies.Length > 0 ? indicies.Slice(1) : indicies); + var model = await ServiceModel(key, defaultDataElementId); + var modelWrapper = new DataModelWrapper(model); + return modelWrapper.GetModelDataCount(key.Field, rowIndexes); } /// @@ -149,193 +80,32 @@ ReadOnlySpan indicies /// "data.bedrifter[1].styre.medlemmer" /// ] /// - public ModelBinding[] GetResolvedKeys(ModelBinding key) + public async Task GetResolvedKeys(DataReference reference) { - var keyParts = key.Field.Split('.'); - return GetResolvedKeysRecursive(key, keyParts, ServiceModel(key)); - } - - private static string JoinFieldKeyParts(string? currentKey, string? key) - { - if (String.IsNullOrEmpty(currentKey)) - { - return key ?? ""; - } - if (String.IsNullOrEmpty(key)) - { - return currentKey; - } - - return currentKey + "." + key; + var model = await ServiceModel(reference.Field, reference.DataElementId); + var modelWrapper = new DataModelWrapper(model); + return modelWrapper + .GetResolvedKeys(reference.Field) + .Select(k => new DataReference { Field = k, DataElementId = reference.DataElementId }) + .ToArray(); } private static readonly Regex _rowIndexRegex = new Regex( @"^([^[\]]+(\[(\d+)])?)+$", RegexOptions.None, - TimeSpan.FromSeconds(1) + TimeSpan.FromMilliseconds(1) ); /// /// Get the row indices from a key /// - public static int[]? GetRowIndices(ModelBinding key) + public static int[]? GetRowIndices(string field) { - var match = _rowIndexRegex.Match(key.Field); + var match = _rowIndexRegex.Match(field); var rowIndices = match.Groups[3].Captures.Select(c => c.Value).Select(int.Parse).ToArray(); return rowIndices.Length == 0 ? null : rowIndices; } - private static ModelBinding[] GetResolvedKeysRecursive( - ModelBinding fullKey, - string[] keyParts, - object? currentModel, - int currentIndex = 0, - string currentKey = "" - ) - { - if (currentModel is null) - { - return []; - } - - if (currentIndex == keyParts.Length) - { - return [fullKey with { Field = currentKey }]; - } - - var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]); - var prop = currentModel.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); - var childModel = prop?.GetValue(currentModel); - if (childModel is null) - { - return []; - } - - if (childModel is not string && childModel is IEnumerable childModelList) - { - // childModel is a list - if (groupIndex is null) - { - // Index not specified, recurse on all elements - int i = 0; - var resolvedKeys = new List(); - foreach (var child in childModelList) - { - var newResolvedKeys = GetResolvedKeysRecursive( - fullKey, - keyParts, - child, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + i + "]") - ); - resolvedKeys.AddRange(newResolvedKeys); - i++; - } - return resolvedKeys.ToArray(); - } - // Index specified, recurse on that element - return GetResolvedKeysRecursive( - fullKey, - keyParts, - childModel, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") - ); - } - - // Otherwise, just recurse - return GetResolvedKeysRecursive( - fullKey, - keyParts, - childModel, - currentIndex + 1, - JoinFieldKeyParts(currentKey, key) - ); - } - - private static object? GetElementAt(IEnumerable enumerable, int index) - { - // Return the element with index = groupIndex (could not find another way to get the nth element in non-generic enumerable) - foreach (var arrayElement in enumerable) - { - if (index-- < 1) - { - return arrayElement; - } - } - - return null; - } - - private static readonly Regex _keyPartRegex = new Regex(@"^([^\s\[\]\.]+)\[(\d+)\]?$"); - - private static (string key, int? index) ParseKeyPart(string keyPart) - { - if (keyPart.Length == 0) - { - throw new DataModelException("Tried to parse empty part of dataModel key"); - } - if (keyPart.Last() != ']') - { - return (keyPart, null); - } - var match = _keyPartRegex.Match(keyPart); - return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); - } - - private static void AddIndiciesRecursive( - List ret, - Type currentModelType, - ReadOnlySpan keys, - ReadOnlySpan indicies - ) - { - if (keys.Length == 0) - { - return; - } - var (key, groupIndex) = ParseKeyPart(keys[0]); - var prop = currentModelType.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); - if (prop is null) - { - throw new DataModelException($"Unknown model property {key} in {string.Join(".", ret)}.{key}"); - } - - var currentIndex = groupIndex ?? (indicies.Length > 0 ? indicies[0] : null); - - var childType = prop.PropertyType; - // Strings are enumerable in C# - // Other enumerable types is treated as a collection - if (childType != typeof(string) && childType.IsAssignableTo(typeof(IEnumerable)) && currentIndex is not null) - { - // Hope the first generic argument is tied to the IEnumerable implementation - var childTypeEnumerableParameter = childType.GetGenericArguments().FirstOrDefault(); - - if (childTypeEnumerableParameter is null) - { - throw new DataModelException("DataModels must have generic IEnumerable<> implementation for list"); - } - - ret.Add($"{key}[{currentIndex}]"); - if (indicies.Length > 0) - { - indicies = indicies.Slice(1); - } - - AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), indicies); - } - else - { - if (groupIndex is not null) - { - throw new DataModelException("Index on non indexable property"); - } - - ret.Add(key); - AddIndiciesRecursive(ret, childType, keys.Slice(1), indicies); - } - } - /// /// Return a full dataModelBiding from a context aware binding by adding indicies /// @@ -344,183 +114,53 @@ ReadOnlySpan indicies /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public ModelBinding AddIndicies(ModelBinding key, ReadOnlySpan indicies = default) + public async Task AddIndexes(ModelBinding key, DataElementId dataElementId, int[]? rowIndexes) { - if (indicies.Length == 0) + if (rowIndexes?.Length < 0) { - return key with { DataType = key.DataType ?? DefaultDataElement.DataType }; + return key; } - var serviceModel = ServiceModel(key); + var serviceModel = await ServiceModel(key, dataElementId); if (serviceModel is null) { throw new DataModelException("Could not find service model for dataType " + key.DataType); } - var ret = new List(); - AddIndiciesRecursive(ret, serviceModel.GetType(), key.Field.Split('.'), indicies); - return new ModelBinding - { - Field = string.Join('.', ret), - DataType = key.DataType ?? DefaultDataElement.DataType - }; - } - - private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key) - { - var ca = propertyInfo.CustomAttributes; - - // Read [JsonPropertyName("propName")] from System.Text.Json - if ( - ca.FirstOrDefault(attr => attr.AttributeType == typeof(JsonPropertyNameAttribute)) - ?.ConstructorArguments.FirstOrDefault() - .Value - is string systemTextJsonAttribute - ) - { - return systemTextJsonAttribute == key; - } - - // Read [JsonProperty("propName")] from Newtonsoft.Json - // To remove dependency on Newtonsoft, while keeping compatibility - // var newtonsoft_json_attribute = (ca.FirstOrDefault(attr => attr.AttributeType.FullName == "Newtonsoft.Json.JsonPropertyAttribute")?.ConstructorArguments.FirstOrDefault().Value as string); - if ( - ca.FirstOrDefault(attr => attr.AttributeType == typeof(Newtonsoft.Json.JsonPropertyAttribute)) - ?.ConstructorArguments.FirstOrDefault() - .Value - is string newtonsoftJsonAttribute - ) - { - return newtonsoftJsonAttribute == key; - } - - // Fallback to property name if all attributes could not be found - var keyName = propertyInfo.Name; - return keyName == key; + var modelWrapper = new DataModelWrapper(serviceModel); + var field = modelWrapper.AddIndicies(key.Field, rowIndexes); + return key with { Field = field }; } /// /// Set the value of a field in the model to default (null) /// - public void RemoveField(ModelBinding key, RowRemovalOption rowRemovalOption) - { - var keysSplit = key.Field.Split('.'); - var keys = keysSplit[0..^1]; - var (lastKey, lastGroupIndex) = ParseKeyPart(keysSplit[^1]); - - var containingObject = GetModelDataRecursive(keys, 0, ServiceModel(key), default); - if (containingObject is null) - { - // Already empty field - return; - } - - if (containingObject is IEnumerable) - { - throw new NotImplementedException($"Tried to remove field {key}, ended in an enumerable"); - } - - var property = containingObject - .GetType() - .GetProperties() - .FirstOrDefault(p => IsPropertyWithJsonName(p, lastKey)); - if (property is null) - { - return; - } - - if (lastGroupIndex is not null) - { - // Remove row from list - var propertyValue = property.GetValue(containingObject); - if (propertyValue is not IList listValue) - { - throw new ArgumentException( - $"Tried to remove row {key}, ended in a non-list ({propertyValue?.GetType()})" - ); - } - - switch (rowRemovalOption) - { - case RowRemovalOption.DeleteRow: - listValue.RemoveAt(lastGroupIndex.Value); - break; - case RowRemovalOption.SetToNull: - var genericType = listValue.GetType().GetGenericArguments().FirstOrDefault(); - var nullValue = genericType?.IsValueType == true ? Activator.CreateInstance(genericType) : null; - listValue[lastGroupIndex.Value] = nullValue; - break; - case RowRemovalOption.Ignore: - return; - } - } - else - { - // Set property to null - var nullValue = property.PropertyType.GetTypeInfo().IsValueType - ? Activator.CreateInstance(property.PropertyType) - : null; - property.SetValue(containingObject, nullValue); - } - } - - /// - /// Verify that a key is valid for the model - /// - public bool VerifyKey(ModelBinding key) + public async void RemoveField( + ModelBinding key, + DataElementId defaultDataElementId, + RowRemovalOption rowRemovalOption + ) { - var serviceModel = ServiceModel(key); + var serviceModel = await ServiceModel(key, defaultDataElementId); if (serviceModel is null) { - return false; - } - return VerifyKeyRecursive(key.Field.Split('.'), 0, serviceModel.GetType()); - } - - /// - /// The default data element when is not set - /// - public DataElement DefaultDataElement { get; } - - private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) - { - if (index == keys.Length) - { - return true; - } - if (keys[index].Length == 0) - { - return false; // invalid key part - } - - var (key, groupIndex) = ParseKeyPart(keys[index]); - var prop = currentModel.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); - if (prop is null) - { - return false; - } - - var childType = prop.PropertyType; - - // Strings are enumerable in C# - // Other enumerable types is treated as a collection - if (childType != typeof(string) && childType.IsAssignableTo(typeof(IEnumerable))) - { - var childTypeEnumerableParameter = childType - .GetInterfaces() - .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - .Select(t => t.GetGenericArguments()[0]) - .FirstOrDefault(); - - if (childTypeEnumerableParameter is not null) - { - return VerifyKeyRecursive(keys, index + 1, childTypeEnumerableParameter); - } - } - else if (groupIndex is not null) - { - return false; // Key parts with group index must be IEnumerable + throw new DataModelException("Could not find service model for dataType " + key.DataType); } - return VerifyKeyRecursive(keys, index + 1, childType); + var modelWrapper = new DataModelWrapper(serviceModel); + modelWrapper.RemoveField(key.Field, rowRemovalOption); } + + // /// + // /// Verify that a key is valid for the model + // /// + // public async Task VerifyKey(ModelBinding key, DataElementId defaultDataElementId) + // { + // var serviceModel = await ServiceModel(key, defaultDataElementId); + // if (serviceModel is null) + // { + // return false; + // } + // var modelWrapper = new DataModelWrapper(serviceModel); + // return modelWrapper.VerifyKey(key.Field); + // } } diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs new file mode 100644 index 000000000..f05674c1b --- /dev/null +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -0,0 +1,460 @@ +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Altinn.App.Core.Helpers.DataModel; + +/// +/// Get data fields from a model, using string keys (like "Bedrifter[1].Ansatte[1].Alder") +/// +public class DataModelWrapper +{ + private readonly object _dataModel; + + /// + /// Constructor that wraps a PCOC data model, and gives extra tool for working with the data in an object using json like keys and reflection + /// + public DataModelWrapper(object dataModel) + { + _dataModel = dataModel; + } + + /// + /// Get model data based on key and optionally indicies + /// + /// + /// Inline indexes in the key "Bedrifter[1].Ansatte[1].Alder" will override + /// normal indexes, and if both "Bedrifter" and "Ansatte" is lists, + /// "Bedrifter[1].Ansatte.Alder", will fail, because the indexes will be reset + /// after an inline index is used + /// + public object? GetModelData(string field, ReadOnlySpan rowIndexes = default) + { + return GetModelDataRecursive(field.Split('.'), 0, _dataModel, rowIndexes); + } + + /// + /// Get the count of data elements set in a group (enumerable) + /// + public int? GetModelDataCount(string field, ReadOnlySpan rowIndexes = default) + { + if ( + GetModelDataRecursive(field.Split('.'), 0, _dataModel, rowIndexes) + is System.Collections.IEnumerable childEnum + ) + { + int retCount = 0; + foreach (var _ in childEnum) + { + retCount++; + } + return retCount; + } + + return null; + } + + private static object? GetModelDataRecursive( + string[] keys, + int index, + object currentModel, + ReadOnlySpan rowIndexes + ) + { + if (index == keys.Length) + { + return currentModel; + } + + var (key, groupIndex) = ParseKeyPart(keys[index]); + var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); + var childModel = prop?.GetValue(currentModel); + if (childModel is null) + { + return null; + } + + // Strings are enumerable in C# + // Other enumerable types is treated as a collection + if (!(childModel is not string && childModel is System.Collections.IEnumerable childModelList)) + { + return GetModelDataRecursive(keys, index + 1, childModel, rowIndexes); + } + + if (groupIndex is null) + { + if (index == keys.Length - 1) + { + return childModelList; + } + + if (rowIndexes.Length == 0) + { + return null; // Error index for collection not specified + } + + groupIndex = rowIndexes[0]; + } + else + { + rowIndexes = default; //when you use a literal index, the context indecies are not to be used later. + } + + var elementAt = GetElementAt(childModelList, groupIndex.Value); + if (elementAt is null) + { + return null; // Error condition, no value at index + } + + return GetModelDataRecursive(keys, index + 1, elementAt, rowIndexes.Length > 0 ? rowIndexes[1..] : rowIndexes); + } + + /// + public string[] GetResolvedKeys(string field) + { + if (_dataModel is null) + { + return []; + } + + var fieldParts = field.Split('.'); + return GetResolvedKeysRecursive(fieldParts, _dataModel); + } + + internal static string JoinFieldKeyParts(string? currentKey, string? key) + { + if (String.IsNullOrEmpty(currentKey)) + { + return key ?? ""; + } + if (String.IsNullOrEmpty(key)) + { + return currentKey ?? ""; + } + + return currentKey + "." + key; + } + + private static readonly Regex _rowIndexRegex = new Regex( + @"^([^[\]]+(\[(\d+)])?)+$", + RegexOptions.None, + TimeSpan.FromSeconds(1) + ); + + private static string[] GetResolvedKeysRecursive( + string[] keyParts, + object currentModel, + int currentIndex = 0, + string currentKey = "" + ) + { + if (currentModel is null) + { + return []; + } + + if (currentIndex == keyParts.Length) + { + return [currentKey]; + } + + var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]); + var prop = currentModel.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + var childModel = prop?.GetValue(currentModel); + if (childModel is null) + { + return []; + } + + if (childModel is not string && childModel is System.Collections.IEnumerable childModelList) + { + // childModel is a list + if (groupIndex is null) + { + // Index not specified, recurse on all elements + int i = 0; + var resolvedKeys = new List(); + foreach (var child in childModelList) + { + var newResolvedKeys = GetResolvedKeysRecursive( + keyParts, + child, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key + "[" + i + "]") + ); + resolvedKeys.AddRange(newResolvedKeys); + i++; + } + return resolvedKeys.ToArray(); + } + // Index specified, recurse on that element + return GetResolvedKeysRecursive( + keyParts, + childModel, + currentIndex + 1, + JoinFieldKeyParts(currentKey, key + "[" + groupIndex + "]") + ); + } + + // Otherwise, just recurse + return GetResolvedKeysRecursive(keyParts, childModel, currentIndex + 1, JoinFieldKeyParts(currentKey, key)); + } + + private static object? GetElementAt(System.Collections.IEnumerable enumerable, int index) + { + // Return the element with index = groupIndex (could not find another way to get the n'th element in non-generic enumerable) + foreach (var arrayElement in enumerable) + { + if (index-- < 1) + { + return arrayElement; + } + } + + return null; + } + + private static readonly Regex _keyPartRegex = new Regex(@"^([^\s\[\]\.]+)\[(\d+)\]?$"); + + internal static (string key, int? index) ParseKeyPart(string keyPart) + { + if (keyPart.Length == 0) + { + throw new DataModelException("Tried to parse empty part of dataModel key"); + } + if (keyPart.Last() != ']') + { + return (keyPart, null); + } + var match = _keyPartRegex.Match(keyPart); + return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); + } + + private static void AddIndiciesRecursive( + List ret, + Type currentModelType, + ReadOnlySpan keys, + ReadOnlySpan indicies + ) + { + if (keys.Length == 0) + { + return; + } + var (key, groupIndex) = ParseKeyPart(keys[0]); + var prop = currentModelType.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + if (prop is null) + { + throw new DataModelException($"Unknown model property {key} in {string.Join(".", ret)}.{key}"); + } + + var currentIndex = groupIndex ?? (indicies.Length > 0 ? indicies[0] : null); + + var childType = prop.PropertyType; + // Strings are enumerable in C# + // Other enumerable types is treated as an collection + if ( + childType != typeof(string) + && childType.IsAssignableTo(typeof(System.Collections.IEnumerable)) + && currentIndex is not null + ) + { + // Hope the first generic argument is tied to the IEnumerable implementation + var childTypeEnumerableParameter = childType.GetGenericArguments().FirstOrDefault(); + + if (childTypeEnumerableParameter is null) + { + throw new DataModelException("DataModels must have generic IEnumerable<> implementation for list"); + } + + ret.Add($"{key}[{currentIndex}]"); + if (indicies.Length > 0) + { + indicies = indicies.Slice(1); + } + + AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), indicies); + } + else + { + if (groupIndex is not null) + { + throw new DataModelException("Index on non indexable property"); + } + + ret.Add(key); + AddIndiciesRecursive(ret, childType, keys.Slice(1), indicies); + } + } + + /// + /// Return a full dataModelBiding from a context aware binding by adding indicies + /// + /// + /// key = "bedrift.ansatte.navn" + /// indicies = [1,2] + /// => "bedrift[1].ansatte[2].navn" + /// + public string AddIndicies(string field, ReadOnlySpan rowIndexes = default) + { + if (rowIndexes.Length == 0) + { + return field; + } + + var ret = new List(); + AddIndiciesRecursive(ret, _dataModel.GetType(), field.Split('.'), rowIndexes); + return string.Join('.', ret); + } + + private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key) + { + var ca = propertyInfo.CustomAttributes; + + // Read [JsonPropertyName("propName")] from System.Text.Json + var system_text_json_attribute = ( + ca.FirstOrDefault(attr => + attr.AttributeType == typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute) + ) + ?.ConstructorArguments.FirstOrDefault() + .Value as string + ); + if (system_text_json_attribute is not null) + { + return system_text_json_attribute == key; + } + + // Read [JsonProperty("propName")] from Newtonsoft.Json + var newtonsoft_json_attribute = ( + ca.FirstOrDefault(attr => attr.AttributeType == typeof(Newtonsoft.Json.JsonPropertyAttribute)) + ?.ConstructorArguments.FirstOrDefault() + .Value as string + ); + // To remove dependency on Newtonsoft, while keeping compatibility + // var newtonsoft_json_attribute = (ca.FirstOrDefault(attr => attr.AttributeType.FullName == "Newtonsoft.Json.JsonPropertyAttribute")?.ConstructorArguments.FirstOrDefault().Value as string); + if (newtonsoft_json_attribute is not null) + { + return newtonsoft_json_attribute == key; + } + + // Fallback to property name if all attributes could not be found + var keyName = propertyInfo.Name; + return keyName == key; + } + + /// + /// Set the value of a field in the model to default (null) + /// + public void RemoveField(string field, RowRemovalOption rowRemovalOption) + { + var fieldSplit = field.Split('.'); + var keys = fieldSplit[0..^1]; + var (lastKey, lastGroupIndex) = ParseKeyPart(fieldSplit[^1]); + + var containingObject = GetModelDataRecursive(keys, 0, _dataModel, default); + if (containingObject is null) + { + // Already empty field + return; + } + + if (containingObject is System.Collections.IEnumerable) + { + throw new NotImplementedException($"Tried to remove field {field}, ended in an enumerable"); + } + + var property = containingObject + .GetType() + .GetProperties() + .FirstOrDefault(p => IsPropertyWithJsonName(p, lastKey)); + if (property is null) + { + return; + } + + if (lastGroupIndex is not null) + { + // Remove row from list + var propertyValue = property.GetValue(containingObject); + if (propertyValue is not System.Collections.IList listValue) + { + throw new ArgumentException( + $"Tried to remove row {field}, ended in a non-list ({propertyValue?.GetType()})" + ); + } + + switch (rowRemovalOption) + { + case RowRemovalOption.DeleteRow: + listValue.RemoveAt(lastGroupIndex.Value); + break; + case RowRemovalOption.SetToNull: + var genericType = listValue.GetType().GetGenericArguments().FirstOrDefault(); + var nullValue = genericType?.IsValueType == true ? Activator.CreateInstance(genericType) : null; + listValue[lastGroupIndex.Value] = nullValue; + break; + case RowRemovalOption.Ignore: + return; + } + } + else + { + // Set property to null + var nullValue = property.PropertyType.GetTypeInfo().IsValueType + ? Activator.CreateInstance(property.PropertyType) + : null; + property.SetValue(containingObject, nullValue); + } + } + + /// + /// Verify that a key is valid for the model + /// + public bool VerifyKey(string field) + { + return VerifyKeyRecursive(field.Split('.'), 0, _dataModel.GetType()); + } + + private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) + { + if (index == keys.Length) + { + return true; + } + if (keys[index].Length == 0) + { + return false; // invalid key part + } + + var (key, groupIndex) = ParseKeyPart(keys[index]); + var prop = currentModel.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + if (prop is null) + { + return false; + } + + var childType = prop.PropertyType; + + // Strings are enumerable in C# + // Other enumerable types is treated as an collection + if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) + { + var childTypeEnumerableParameter = childType + .GetInterfaces() + .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + .Select(t => t.GetGenericArguments()[0]) + .FirstOrDefault(); + + if (childTypeEnumerableParameter is not null) + { + return VerifyKeyRecursive(keys, index + 1, childTypeEnumerableParameter); + } + } + else if (groupIndex is not null) + { + return false; // Key parts with group index must be IEnumerable + } + + return VerifyKeyRecursive(keys, index + 1, childType); + } +} diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index 448adc7b0..c7baa85e9 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -3,6 +3,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Internal.Data; @@ -14,7 +15,6 @@ namespace Altinn.App.Core.Internal.Data; /// internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor { - private readonly Instance _instance; private readonly string _org; private readonly string _app; private readonly Guid _instanceGuid; @@ -22,7 +22,7 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; private readonly IAppModel _appModel; - private readonly LazyCache _cache = new(); + private readonly LazyCache _cache = new(); public CachedInstanceDataAccessor( Instance instance, @@ -37,34 +37,59 @@ IAppModel appModel var splitId = instance.Id.Split("/"); _instanceOwnerPartyId = int.Parse(splitId[0], CultureInfo.InvariantCulture); _instanceGuid = Guid.Parse(splitId[1]); - _instance = instance; + Instance = instance; _dataClient = dataClient; _appMetadata = appMetadata; _appModel = appModel; } - public Instance Instance => _instance; + public Instance Instance { get; } + + public async Task GetSingleDataByType(string dataType) + { + var appMetadata = await _appMetadata.GetApplicationMetadata(); + var dataTypeObj = appMetadata.DataTypes.Find(d => d.Id == dataType); + if (dataTypeObj == null) + { + throw new InvalidOperationException($"Data type {dataType} not found in app metadata"); + } + if (dataTypeObj?.MaxCount != 1) + { + throw new InvalidOperationException($"Data type {dataType} is not a single data type"); + } + var dataElement = Instance.Data.Find(d => d.DataType == dataType); + if (dataElement == null) + { + return null; + } + + return await GetData(dataElement); + } /// - public async Task GetData(DataElement dataElement) + public async Task GetData(DataElementId dataElementId) { return await _cache.GetOrCreate( - dataElement.Id, + dataElementId, async _ => { var appMetadata = await _appMetadata.GetApplicationMetadata(); - var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + var dataElementIdString = dataElementId.Id.ToString(); + var dataElementType = Instance.Data.Find(d => d.Id == dataElementIdString)?.DataType; + var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElementType); if (dataType == null) { - throw new InvalidOperationException($"Data type {dataElement.DataType} not found in app metadata"); + throw new InvalidOperationException( + $"Data type {dataElementType ?? "null"} for data element id {dataElementId} not found in app metadata" + ); } if (dataType.AppLogic?.ClassRef != null) { - return await GetFormData(dataElement, dataType); + return await GetFormData(dataElementId, dataType); } - return await GetBinaryData(dataElement); + return await GetBinaryData(dataElementId); } ); } @@ -72,11 +97,9 @@ public async Task GetData(DataElement dataElement) /// /// Add data to the cache, so that it won't be fetched again /// - /// - /// - public void Set(DataElement dataElement, object data) + public void Set(DataElementId dataElementId, object data) { - _cache.Set(dataElement.Id, data); + _cache.Set(dataElementId, data); } /// @@ -113,19 +136,13 @@ public void Set(TKey key, TValue data) } } - private async Task GetBinaryData(DataElement dataElement) + private async Task GetBinaryData(DataElementId dataElementId) { - var data = await _dataClient.GetBinaryData( - _org, - _app, - _instanceOwnerPartyId, - _instanceGuid, - Guid.Parse(dataElement.Id) - ); + var data = await _dataClient.GetBinaryData(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Id); return data; } - private async Task GetFormData(DataElement dataElement, DataType dataType) + private async Task GetFormData(DataElementId dataElementId, DataType dataType) { var modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); @@ -135,7 +152,7 @@ private async Task GetFormData(DataElement dataElement, DataType dataTyp _org, _app, _instanceOwnerPartyId, - Guid.Parse(dataElement.Id) + dataElementId.Id ); return data; } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 5b4755756..712af7f02 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.RegularExpressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -14,7 +15,7 @@ public static class ExpressionEvaluator /// /// Shortcut for evaluating a boolean expression on a given property on a /// - public static bool EvaluateBooleanExpression( + public static async Task EvaluateBooleanExpression( LayoutEvaluatorState state, ComponentContext context, string property, @@ -33,7 +34,7 @@ bool defaultReturn _ => throw new ExpressionEvaluatorTypeErrorException($"unknown boolean expression property {property}") }; - return EvaluateExpression(state, expr, context) switch + return await EvaluateExpression(state, expr, context) switch { true => true, false => false, @@ -53,10 +54,10 @@ bool defaultReturn /// /// Evaluate a from a given in a /// - public static object? EvaluateExpression( + public static async Task EvaluateExpression( LayoutEvaluatorState state, Expression expr, - ComponentContext? context, + ComponentContext context, object[]? positionalArguments = null ) { @@ -64,13 +65,17 @@ bool defaultReturn { return expr.Value; } - - var args = expr.Args.Select(a => EvaluateExpression(state, a, context, positionalArguments)).ToArray(); + var args = new object?[expr.Args.Count]; + for (var i = 0; i < args.Length; i++) + { + args[i] = await EvaluateExpression(state, expr.Args[i], context, positionalArguments); + } + // var args = expr.Args.Select(a => await EvaluateExpression(state, a, context, positionalArguments)).ToArray(); // ! TODO: should find better ways to deal with nulls here for the next major version var ret = expr.Function switch { - ExpressionFunction.dataModel => DataModel(args, context, state), - ExpressionFunction.component => Component(args, context, state), + ExpressionFunction.dataModel => await DataModel(args, context, state), + ExpressionFunction.component => await Component(args, context, state), ExpressionFunction.instanceContext => state.GetInstanceContext(args.First()?.ToString()!), ExpressionFunction.@if => IfImpl(args), ExpressionFunction.frontendSettings => state.GetFrontendSetting(args.First()?.ToString()!), @@ -101,11 +106,15 @@ bool defaultReturn return ret; } - private static object? DataModel(object?[] args, ComponentContext? context, LayoutEvaluatorState state) + private static async Task DataModel(object?[] args, ComponentContext context, LayoutEvaluatorState state) { + if (args is [DataReference dataReference]) + { + return await DataModel(dataReference.Field, dataReference.DataElementId, context.RowIndices, state); + } var key = args switch { - [string field] => new ModelBinding { Field = field, DataType = state.DefaultDataElement.DataType }, + [string field] => new ModelBinding { Field = field }, [string field, string dataType] => new ModelBinding { Field = field, DataType = dataType }, [ModelBinding binding] => binding, [null] => throw new ExpressionEvaluatorTypeErrorException("Cannot lookup dataModel null"), @@ -114,12 +123,17 @@ bool defaultReturn $"""Expected ["dataModel", ...] to have 1-2 argument(s), got {args.Length}""" ) }; - return DataModel(key, context, state); + return await DataModel(key, context.DataElementId, context.RowIndices, state); } - private static object? DataModel(ModelBinding key, ComponentContext? context, LayoutEvaluatorState state) + private static async Task DataModel( + ModelBinding key, + DataElementId defaultDataElementId, + int[]? indexes, + LayoutEvaluatorState state + ) { - var data = state.GetModelData(key, context); + var data = await state.GetModelData(key, defaultDataElementId, indexes); // Only allow IConvertible types to be returned from data model // Objects and arrays should return null @@ -130,7 +144,7 @@ bool defaultReturn }; } - private static object? Component(object?[] args, ComponentContext? context, LayoutEvaluatorState state) + private static async Task Component(object?[] args, ComponentContext? context, LayoutEvaluatorState state) { var componentId = args.First()?.ToString(); if (componentId is null) @@ -143,7 +157,12 @@ bool defaultReturn throw new ArgumentException("The component expression requires a component context"); } - var targetContext = state.GetComponentContext(context.Component.PageId, componentId, context.RowIndices); + var targetContext = await state.GetComponentContext( + context.Component.PageId, + componentId, + context.DataElementId, + context.RowIndices + ); if (targetContext.Component is GroupComponent) { @@ -157,7 +176,7 @@ bool defaultReturn ComponentContext? parent = targetContext; while (parent is not null) { - if (EvaluateBooleanExpression(state, parent, "hidden", false)) + if (await EvaluateBooleanExpression(state, parent, "hidden", false)) { // Don't lookup data in hidden components return null; @@ -165,7 +184,7 @@ bool defaultReturn parent = parent.Parent; } - return DataModel(binding, context, state); + return await DataModel(binding, context.DataElementId, context.RowIndices, state); } private static string? Concat(object?[] args) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 49ecb4e1d..b6a173d07 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -14,17 +16,17 @@ public static class LayoutEvaluator /// /// Get a list of fields that are only referenced in hidden components in /// - public static List GetHiddenFieldsForRemoval( + public static async Task> GetHiddenFieldsForRemoval( LayoutEvaluatorState state, bool includeHiddenRowChildren = false ) { - var hiddenModelBindings = new HashSet(); - var nonHiddenModelBindings = new HashSet(); + var hiddenModelBindings = new HashSet(); + var nonHiddenModelBindings = new HashSet(); - foreach (var context in state.GetComponentContexts()) + foreach (var context in await state.GetComponentContexts()) { - HiddenFieldsForRemovalRecurs( + await HiddenFieldsForRemovalRecurs( state, includeHiddenRowChildren, hiddenModelBindings, @@ -34,15 +36,22 @@ public static List GetHiddenFieldsForRemoval( } var forRemoval = hiddenModelBindings.Except(nonHiddenModelBindings); - var existsForRemoval = forRemoval.Where(key => state.GetModelData(key) is not null); - return existsForRemoval.ToList(); + var existsForRemoval = new List(); + foreach (var keyToRemove in forRemoval) + { + if (await state.GetModelData(keyToRemove.Field, keyToRemove.DataElementId, default) is not null) + { + existsForRemoval.Add(keyToRemove); + } + } + return existsForRemoval; } - private static void HiddenFieldsForRemovalRecurs( + private static async Task HiddenFieldsForRemovalRecurs( LayoutEvaluatorState state, bool includeHiddenRowChildren, - HashSet hiddenModelBindings, - HashSet nonHiddenModelBindings, + HashSet hiddenModelBindings, + HashSet nonHiddenModelBindings, ComponentContext context ) { @@ -60,7 +69,7 @@ ComponentContext context } } - HiddenFieldsForRemovalRecurs( + await HiddenFieldsForRemovalRecurs( state, includeHiddenRowChildren, hiddenModelBindings, @@ -79,14 +88,26 @@ context.Component is RepeatingGroupComponent repGroup foreach (var index in Enumerable.Range(0, context.RowLength.Value).Reverse()) { var rowIndices = context.RowIndices?.Append(index).ToArray() ?? [index]; - var indexedBinding = state.AddInidicies(repGroup.DataModelBindings["group"], rowIndices); + var newContext = new ComponentContext( + context.Component, + rowIndices, + rowLength: null, + dataElementId: context.DataElementId, + childContexts: context.ChildContexts + ); + var indexedBinding = await state.AddInidicies(repGroup.DataModelBindings["group"], newContext); + var fieldReference = new DataReference() + { + Field = indexedBinding.Field, + DataElementId = newContext.DataElementId + }; if (context.HiddenRows.Contains(index)) { - hiddenModelBindings.Add(indexedBinding); + hiddenModelBindings.Add(fieldReference); } else { - nonHiddenModelBindings.Add(indexedBinding); + nonHiddenModelBindings.Add(fieldReference); } } } @@ -101,48 +122,53 @@ context.Component is RepeatingGroupComponent repGroup continue; } - var indexed_binding = state.AddInidicies(binding, context); + var indexedBinding = await state.AddInidicies(binding, context); + var fieldReference = new DataReference() + { + Field = indexedBinding.Field, + DataElementId = context.DataElementId + }; if (context.IsHidden == true) { - hiddenModelBindings.Add(indexed_binding); + hiddenModelBindings.Add(fieldReference); } else { - nonHiddenModelBindings.Add(indexed_binding); + nonHiddenModelBindings.Add(fieldReference); } } } } /// - /// Remove fields that are only refrenced from hidden fields from the data object in the state. + /// Remove fields that are only referenced from hidden fields from the data object in the state. /// - public static void RemoveHiddenData(LayoutEvaluatorState state, RowRemovalOption rowRemovalOption) + public static async Task RemoveHiddenData(LayoutEvaluatorState state, RowRemovalOption rowRemovalOption) { - var fields = GetHiddenFieldsForRemoval(state); - foreach (var field in fields) + var fields = await GetHiddenFieldsForRemoval(state); + foreach (var dataReference in fields) { - state.RemoveDataField(field, rowRemovalOption); + state.RemoveDataField(dataReference.Field, dataReference.DataElementId, rowRemovalOption); } } /// /// Return a list of for the given state and dataElementId /// - public static List RunLayoutValidationsForRequired(LayoutEvaluatorState state) + public static async Task> RunLayoutValidationsForRequired(LayoutEvaluatorState state) { var validationIssues = new List(); - foreach (var context in state.GetComponentContexts()) + foreach (var context in await state.GetComponentContexts()) { - RunLayoutValidationsForRequiredRecurs(validationIssues, state, context); + await RunLayoutValidationsForRequiredRecurs(validationIssues, state, context); } return validationIssues; } - private static void RunLayoutValidationsForRequiredRecurs( + private static async Task RunLayoutValidationsForRequiredRecurs( List validationIssues, LayoutEvaluatorState state, ComponentContext context @@ -152,22 +178,24 @@ ComponentContext context { foreach (var childContext in context.ChildContexts) { - RunLayoutValidationsForRequiredRecurs(validationIssues, state, childContext); + await RunLayoutValidationsForRequiredRecurs(validationIssues, state, childContext); } - var required = ExpressionEvaluator.EvaluateBooleanExpression(state, context, "required", false); + var required = await ExpressionEvaluator.EvaluateBooleanExpression(state, context, "required", false); if (required && context.Component is not null) { foreach (var (bindingName, binding) in context.Component.DataModelBindings) { - if (state.GetModelData(binding, context) is null) + if (await state.GetModelData(binding, context.DataElementId, context.RowIndices) is null) { - var field = state.AddInidicies(binding, context); + var field = await state.AddInidicies(binding, context); + DataElementId dataElementId = context.DataElementId; + if (field.DataType is not null) { } validationIssues.Add( new ValidationIssue() { Severity = ValidationIssueSeverity.Error, - DataElementId = state.GetDataElement(field)?.Id, + DataElementId = dataElementId.ToString(), Field = field.Field, Description = $"{field.Field} is required in component with id {context.Component.Id}", Code = "required", diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index f62dc46f3..e4d2927d8 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,5 +1,6 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -13,12 +14,12 @@ namespace Altinn.App.Core.Internal.Expressions; public class LayoutEvaluatorState { private readonly DataModel _dataModel; - private readonly LayoutModel _componentModel; + private readonly LayoutModel? _componentModel; + private readonly DataElementId _defaultDataElementId; private readonly FrontEndSettings _frontEndSettings; private readonly Instance _instanceContext; private readonly string? _gatewayAction; private readonly string? _language; - private readonly ComponentContext[]? _pageContexts; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. @@ -38,68 +39,72 @@ public LayoutEvaluatorState( _instanceContext = instance; _gatewayAction = gatewayAction; _language = language; - - if (dataModel is not null && componentModel is not null) - { - _pageContexts = GenerateComponentContexts(dataModel, componentModel); - EvaluateHiddenExpressions(); - } + var defaultDataType = _componentModel.DefaultDataType.Id; + _defaultDataElementId = + _instanceContext.Data.Find(d => d.DataType == defaultDataType) + ?? throw new ArgumentException($"Could not find data element with data type {defaultDataType}"); } - /// - /// Get the default data element for the layout when is null - /// - public DataElement DefaultDataElement => _dataModel.DefaultDataElement; - /// /// Get a hierarchy of the different contexts in the component model (remember to iterate ) /// - public IEnumerable GetComponentContexts() + public async Task> GetComponentContexts() { - if (_pageContexts is null) - { - throw new ArgumentException("ComponentContexts have not been generated"); - } - return _pageContexts; + var contexts = await Task.WhenAll( + _componentModel.Pages.Values.Select( + (async (page) => await GeneratePageContext(page, _dataModel, _defaultDataElementId)) + ) + ); + + await EvaluateHiddenExpressions(contexts); + return contexts; } - private static ComponentContext[] GenerateComponentContexts(DataModel dataModel, LayoutModel componentModel) + private static async Task GeneratePageContext( + PageComponent page, + DataModel dataModel, + DataElementId dataElementId + ) { - return componentModel.Pages.Values.Select(((page) => GeneratePageContext(page, dataModel))).ToArray(); - } + var children = new List(); + foreach (var child in page.Children) + { + children.Add(await GenerateComponentContextsRecurs(child, dataModel, dataElementId, [])); + } - private static ComponentContext GeneratePageContext(PageComponent page, DataModel dataModel) => - new ComponentContext( - page, - null, - null, - page.Children.Select(c => GenerateComponentContextsRecurs(c, dataModel, [])).ToArray() - ); + return new ComponentContext(page, null, null, dataElementId, children); + } - private static ComponentContext GenerateComponentContextsRecurs( + private static async Task GenerateComponentContextsRecurs( BaseComponent component, DataModel dataModel, - ReadOnlySpan indexes + DataElementId defaultDataElementId, + int[]? indexes ) { var children = new List(); int? rowLength = null; + if ( + true /*TODO: type is subform*/ + ) { } if (component is RepeatingGroupComponent repeatingGroupComponent) { if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) { - rowLength = dataModel.GetModelDataCount(groupBinding, indexes.ToArray()) ?? 0; + rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; foreach (var index in Enumerable.Range(0, rowLength.Value)) { foreach (var child in repeatingGroupComponent.Children) { // concatenate [...indexes, index] - var subIndexes = new int[indexes.Length + 1]; + var subIndexes = new int[(indexes?.Length ?? 0) + 1]; indexes.CopyTo(subIndexes.AsSpan()); subIndexes[^1] = index; - children.Add(GenerateComponentContextsRecurs(child, dataModel, subIndexes)); + children.Add( + await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) + ); } } } @@ -108,16 +113,17 @@ ReadOnlySpan indexes { foreach (var child in groupComponent.Children) { - children.Add(GenerateComponentContextsRecurs(child, dataModel, indexes)); + children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); } } - return new ComponentContext(component, ToArrayOrNullForEmpty(indexes), rowLength, children); - } - - private static T[]? ToArrayOrNullForEmpty(ReadOnlySpan span) - { - return span.Length > 0 ? span.ToArray() : null; + return new ComponentContext( + component, + indexes?.Length > 0 ? indexes : null, + rowLength, + defaultDataElementId, + children + ); } /// @@ -125,7 +131,7 @@ ReadOnlySpan indexes /// public string? GetFrontendSetting(string key) { - return _frontEndSettings.TryGetValue(key, out var setting) ? setting : null; + return _frontEndSettings.GetValueOrDefault(key); } /// @@ -144,83 +150,58 @@ public BaseComponent GetComponent(string pageName, string componentId) /// /// Get a specific component context based on /// - public ComponentContext GetComponentContext(string pageName, string componentId, int[]? rowIndicies = null) + public async Task GetComponentContext( + string pageName, + string componentId, + DataElementId defaultDataElementId, + int[]? rowIndexes = null + ) { - if (_pageContexts is null) - { - throw new ArgumentException("ComponentContexts have not been generated"); - } // First look only on the relevant page - var pageContext = _pageContexts.FirstOrDefault(c => c.Component?.Id == pageName); - if (pageContext is null) + _componentModel.Pages.TryGetValue(pageName, out var page); + if (page is null) { throw new ArgumentException($"Unknown page name {pageName}"); } - // Find all descendant contexts that matches componentId and all the given rowIndicies - var matches = pageContext - .Descendants.Where(context => - context.Component?.Id == componentId - && ( - context.RowIndices?.Zip(rowIndicies ?? Enumerable.Empty()).All((i) => i.First == i.Second) - ?? true - ) - ) - .ToArray(); - if (matches.Length == 1) - { - return matches[0]; - } - else if (matches.Length > 1) + page.ComponentLookup.TryGetValue(componentId, out var component); + if (component is null) { - throw new ArgumentException( - $"Expected 1 matching component context for [\"component\"] lookup on page {pageName}. Found {matches.Length}" - ); + // Look for component on other pages + component = _componentModel + .Pages.Values.Select(p => p.ComponentLookup.GetValueOrDefault(componentId)) + .Single(c => c is not null); } - // If no components was found on the same page, look for component on all pages - // Find all decendent contexts that matches componentId and all the given rowIndicies - matches = _pageContexts - .SelectMany(p => - p.Descendants.Where(context => - context.Component?.Id == componentId - && ( - context.RowIndices?.Zip(rowIndicies ?? Enumerable.Empty()).All((i) => i.First == i.Second) - ?? true - ) - ) - ) - .ToArray(); - if (matches.Length != 1) + if (component is null) { - throw new ArgumentException( - "Expected 1 matching component context for [\"component\"] lookup. Found " + matches.Length - ); + throw new ArgumentException($"Unknown component id {componentId}"); } - return matches[0]; + + return await GenerateComponentContextsRecurs(component, _dataModel, defaultDataElementId, rowIndexes); } /// /// Get field from dataModel with key and context /// - public object? GetModelData(ModelBinding key, ComponentContext? context = null) + public async Task GetModelData(ModelBinding key, DataElementId defaultDataElementId, int[]? indexes) { - return _dataModel.GetModelData(key, context?.RowIndices); + return await _dataModel.GetModelData(key, defaultDataElementId, indexes); } /// /// Get all of the resolved keys (including all possible indexes) from a data model key /// - public ModelBinding[] GetResolvedKeys(ModelBinding key) + public async Task GetResolvedKeys(DataReference reference) { - return _dataModel.GetResolvedKeys(key); + return await _dataModel.GetResolvedKeys(reference); } /// /// Set the value of a field to null. /// - public void RemoveDataField(ModelBinding key, RowRemovalOption rowRemovalOption) + public void RemoveDataField(ModelBinding key, DataElementId dataElementId, RowRemovalOption rowRemovalOption) { - _dataModel.RemoveField(key, rowRemovalOption); + _dataModel.RemoveField(key, dataElementId, rowRemovalOption); } /// @@ -265,83 +246,85 @@ public string GetInstanceContext(string key) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public ModelBinding AddInidicies(ModelBinding binding, ComponentContext context) - { - return _dataModel.AddIndicies(binding, context.RowIndices); - } - - /// - /// Return a full dataModelBiding from a context aware binding by adding indicies - /// - public ModelBinding AddInidicies(ModelBinding binding, ReadOnlySpan indices) + public async Task AddInidicies(ModelBinding binding, ComponentContext context) { - return _dataModel.AddIndicies(binding, indices); + return await _dataModel.AddIndexes(binding, context.DataElementId, context.RowIndices); } /// - /// Verify all components that dataModel references are correct + /// Return a full dataModelBiding from a context aware binding by adding indexes /// - public List GetModelErrors() + public async Task AddInidicies(ModelBinding binding, DataElementId dataElementId, int[]? indexes) { - var errors = new List(); - foreach (var component in _componentModel.GetComponents()) - { - GetModelErrorsForExpression(component.Hidden, component, errors); - GetModelErrorsForExpression(component.Required, component, errors); - GetModelErrorsForExpression(component.ReadOnly, component, errors); - foreach (var (bindingName, binding) in component.DataModelBindings) - { - if (!_dataModel.VerifyKey(binding)) - { - errors.Add($"Invalid binding \"{binding}\" on component {component.PageId}.{component.Id}"); - } - } - } - return errors; + return await _dataModel.AddIndexes(binding, dataElementId, indexes); } - private void GetModelErrorsForExpression(Expression expr, BaseComponent component, List errors) + // /// + // /// Verify all components that dataModel references are correct + // /// + // public List GetModelErrors() + // { + // var errors = new List(); + // foreach (var context in GetComponentContexts()) + // { + // var component = context.Component; + // GetModelErrorsForExpression(component.Hidden, component, errors); + // GetModelErrorsForExpression(component.Required, component, errors); + // GetModelErrorsForExpression(component.ReadOnly, component, errors); + // foreach (var (bindingName, binding) in component.DataModelBindings) + // { + // if (!_dataModel.VerifyKey(binding, context.DataElementId)) + // { + // errors.Add($"Invalid binding \"{binding}\" on component {component.PageId}.{component.Id}"); + // } + // } + // } + // return errors; + // } + + // private void GetModelErrorsForExpression(Expression expr, BaseComponent component, List errors) + // { + // if (!expr.IsFunctionExpression) + // { + // return; + // } + // + // if (expr.Function == ExpressionFunction.dataModel) + // { + // if (expr.Args.Count != 1 || expr.Args[0].Value is not string binding) + // { + // errors.Add( + // $"function \"dataModel\" requires a single string argument on component {component.PageId}.{component.Id}" + // ); + // return; + // } + // var dataType = expr.Args.ElementAtOrDefault(1).Value as string; + // if (!_dataModel.VerifyKey(new ModelBinding { Field = binding, DataType = dataType }, dataElementId)) + // { + // errors.Add($"Invalid binding \"{binding}\" on component {component.PageId}.{component.Id}"); + // } + // return; + // } + // + // // check args recursively + // foreach (var arg in expr.Args) + // { + // GetModelErrorsForExpression(arg, component, errors); + // } + // } + + private async Task EvaluateHiddenExpressions(IEnumerable contexts) { - if (!expr.IsFunctionExpression) + foreach (var context in contexts) { - return; - } - - if (expr.Function == ExpressionFunction.dataModel) - { - if (expr.Args.Count != 1 || expr.Args[0].Value is not string binding) - { - errors.Add( - $"function \"dataModel\" requires a single string argument on component {component.PageId}.{component.Id}" - ); - return; - } - var dataType = expr.Args.ElementAtOrDefault(1).Value as string; - if (!_dataModel.VerifyKey(new ModelBinding { Field = binding, DataType = dataType })) - { - errors.Add($"Invalid binding \"{binding}\" on component {component.PageId}.{component.Id}"); - } - return; - } - - // check args recursivly - foreach (var arg in expr.Args) - { - GetModelErrorsForExpression(arg, component, errors); - } - } - - private void EvaluateHiddenExpressions() - { - foreach (var context in GetComponentContexts()) - { - EvaluateHiddenExpressionRecurs(context); + await EvaluateHiddenExpressionRecurs(context); } } - private void EvaluateHiddenExpressionRecurs(ComponentContext context, bool parentIsHidden = false) + private async Task EvaluateHiddenExpressionRecurs(ComponentContext context, bool parentIsHidden = false) { - var hidden = parentIsHidden || ExpressionEvaluator.EvaluateBooleanExpression(this, context, "hidden", false); + var hidden = + parentIsHidden || await ExpressionEvaluator.EvaluateBooleanExpression(this, context, "hidden", false); context.IsHidden = hidden; if ( @@ -354,9 +337,20 @@ context.Component is RepeatingGroupComponent repGroup foreach (var index in Enumerable.Range(0, context.RowLength.Value)) { var rowIndices = context.RowIndices?.Append(index).ToArray() ?? [index]; - var childContexts = context.ChildContexts.Where(c => c.RowIndices?.Last() == index); - var rowContext = new ComponentContext(context.Component, rowIndices, null, childContexts); - var rowHidden = ExpressionEvaluator.EvaluateBooleanExpression(this, rowContext, "hiddenRow", false); + var childContexts = context.ChildContexts.Where(c => c.RowIndices?[^1] == index); + var rowContext = new ComponentContext( + context.Component, + rowIndices, + rowLength: null, + dataElementId: context.DataElementId, + childContexts: childContexts + ); + var rowHidden = await ExpressionEvaluator.EvaluateBooleanExpression( + this, + rowContext, + "hiddenRow", + false + ); if (rowHidden) { hiddenRows.Add(index); @@ -373,20 +367,12 @@ context.Component is RepeatingGroupComponent repGroup var currentRow = childContext.RowIndices?.Last(); rowIsHidden = currentRow is not null && context.HiddenRows.Contains(currentRow.Value); } - EvaluateHiddenExpressionRecurs(childContext, hidden || rowIsHidden); + await EvaluateHiddenExpressionRecurs(childContext, hidden || rowIsHidden); } } - /// - /// Get the data element that this ModelBinding is pointing to - /// - public DataElement? GetDataElement(ModelBinding field) + public DataElementId GetDefaultElementId() { - if (field.DataType is null) - { - return DefaultDataElement; - } - var dataElement = _instanceContext.Data.Find(d => d.DataType == field.DataType); - return dataElement; + return _defaultDataElementId; } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 5aadc89f8..52c122a39 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -3,6 +3,8 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -26,6 +28,50 @@ public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions + /// Helper class to keep compatibility with old interface + /// delete when + /// is removed + /// + private class SingleDataElementAccessor : IInstanceDataAccessor + { + private readonly DataElement _dataElement; + private readonly object _data; + + public SingleDataElementAccessor(Instance instance, DataElement dataElement, object data) + { + Instance = instance; + _dataElement = dataElement; + _data = data; + } + + public Instance Instance { get; } + + public Task GetData(DataElementId dataElementId) + { + if (dataElementId != _dataElement) + { + return Task.FromException( + new InvalidOperationException( + "Use the new ILayoutEvaluatorStateInitializer interface to support multiple data models and subforms" + ) + ); + } + return Task.FromResult(_data); + } + + public Task GetSingleDataByType(string dataType) + { + if (_dataElement.DataType != dataType) + { + return Task.FromException( + new InvalidOperationException("Data type does not match the data element") + ); + } + return Task.FromResult(_data); + } + } + /// /// Initialize LayoutEvaluatorState with given Instance, data object and layoutSetId /// @@ -40,14 +86,9 @@ public Task Init( var layouts = _appResources.GetLayoutModel(layoutSetId); var dataElement = instance.Data.Find(d => d.DataType == layouts.DefaultDataType.Id); Debug.Assert(dataElement is not null); + var dataAccessor = new SingleDataElementAccessor(instance, dataElement, data); return Task.FromResult( - new LayoutEvaluatorState( - new DataModel([KeyValuePair.Create(dataElement, data)]), - layouts, - _frontEndSettings, - instance, - gatewayAction - ) + new LayoutEvaluatorState(new DataModel(dataAccessor), layouts, _frontEndSettings, instance, gatewayAction) ); } @@ -62,38 +103,8 @@ public async Task Init( { var layouts = _appResources.GetLayoutModelForTask(taskId); - var defaultDataTypeId = layouts.DefaultDataType.Id; - var defaultDataElement = instance.Data.Find(d => d.DataType == defaultDataTypeId); - if (defaultDataElement is null) - { - throw new InvalidOperationException($"No data element found for data type {defaultDataTypeId}"); - } - - var dataTasks = new List>>(); - foreach (var dataType in layouts.GetReferencedDataTypeIds()) - { - // Find first data element of type dataType - var dataElement = instance.Data.Find(d => d.DataType == dataType); - if (dataElement is not null) - { - dataTasks.Add( - Task.Run(async () => KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement))) - ); - } - // TODO: This will change when subforms use the same data type for multiple data elemetns. - // dataTasks.AddRange( - // instance - // .Data.Where(dataElement => dataElement.DataType == dataType) - // .Select(async dataElement => - // KeyValuePair.Create(dataElement, await dataAccessor.GetData(dataElement)) - // ) - // ); - } - - var extraModels = await Task.WhenAll(dataTasks); - return new LayoutEvaluatorState( - new DataModel(extraModels), + new DataModel(dataAccessor), layouts, _frontEndSettings, instance, diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index a8282326b..e05086421 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -43,41 +43,50 @@ public async Task> FilterAsync( ProcessGatewayInformation processGatewayInformation ) { - var state = await GetLayoutEvaluatorState( + var taskId = instance.Process.CurrentTask.ElementId; + var state = await _layoutStateInit.Init( instance, dataAccessor, - instance.Process.CurrentTask.ElementId, + taskId, processGatewayInformation.Action, language: null ); - return outgoingFlows.Where(outgoingFlow => EvaluateSequenceFlow(state, outgoingFlow)).ToList(); - } + var flows = new List(); + foreach (var outgoingFlow in outgoingFlows) + { + if (await EvaluateSequenceFlow(state, outgoingFlow, processGatewayInformation)) + { + flows.Add(outgoingFlow); + } + } - private async Task GetLayoutEvaluatorState( - Instance instance, - IInstanceDataAccessor dataAccessor, - string taskId, - string? gatewayAction, - string? language - ) - { - var state = await _layoutStateInit.Init(instance, dataAccessor, taskId, gatewayAction, language); - return state; + return flows; } - private static bool EvaluateSequenceFlow(LayoutEvaluatorState state, SequenceFlow sequenceFlow) + private static async Task EvaluateSequenceFlow(LayoutEvaluatorState state, SequenceFlow sequenceFlow, ProcessGatewayInformation processGatewayInformation) { if (sequenceFlow.ConditionExpression != null) { var expression = GetExpressionFromCondition(sequenceFlow.ConditionExpression); + // If there is no component context in the state, evaluate the expression once without a component context - var stateComponentContexts = state.GetComponentContexts().Any() - ? state.GetComponentContexts().ToList() - : [null]; + var stateComponentContexts = (await state.GetComponentContexts()).ToList(); + if (stateComponentContexts.Count == 0) + { + stateComponentContexts.Add( + new ComponentContext( + component: null, + rowIndices: null, + rowLength: null, + dataElementId: processGatewayInformation.DataTypeId, + childContexts: null + ) + ); + } foreach (ComponentContext? componentContext in stateComponentContexts) { - var result = ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); + var result = await ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); if (result is true) { return true; diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 635e6088f..f5d5f34b1 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -146,7 +146,7 @@ private async Task RemoveFieldsOnTaskComplete( gatewayAction: null, language ); - LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.Ignore); + await LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.DeleteRow); // TODO: Make RemoveHiddenData return a bool indicating if data was removed isModified = true; } diff --git a/src/Altinn.App.Core/Models/DataElementId.cs b/src/Altinn.App.Core/Models/DataElementId.cs new file mode 100644 index 000000000..88eece842 --- /dev/null +++ b/src/Altinn.App.Core/Models/DataElementId.cs @@ -0,0 +1,23 @@ +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Models; + +/// +/// Wrapper type for a +/// +/// The guid ID +public readonly record struct DataElementId(Guid Id) +{ + /// + /// Implicit conversion to allow DataElements to be used as DataElementIds + /// + public static implicit operator DataElementId(DataElement dataElement) => new(Guid.Parse(dataElement.Id)); + + /// + /// Make the ToString method return the ID + /// + public override string ToString() + { + return Id.ToString(); + } +} diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 8c289194e..68151bd8d 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -16,13 +16,15 @@ public ComponentContext( BaseComponent? component, int[]? rowIndices, int? rowLength, + DataElementId dataElementId, IEnumerable? childContexts = null ) { + DataElementId = dataElementId; Component = component; RowIndices = rowIndices; RowLength = rowLength; - ChildContexts = childContexts ?? Enumerable.Empty(); + ChildContexts = childContexts ?? []; foreach (var child in ChildContexts) { child.Parent = this; @@ -35,7 +37,7 @@ public ComponentContext( public BaseComponent? Component { get; } /// - /// The indicies for this context (in case the component is part of a repeating group) + /// The indexes for this context (in case the component is part of a repeating group) /// public int[]? RowIndices { get; } @@ -45,7 +47,7 @@ public ComponentContext( public int? RowLength { get; } /// - /// Whether or not the component is hidden + /// Whether the component is hidden /// public bool? IsHidden { get; set; } @@ -62,7 +64,12 @@ public ComponentContext( /// /// Parent context or null, if this is a root context, or a context created without setting parent /// - public ComponentContext? Parent { get; set; } + public ComponentContext? Parent { get; private set; } + + /// + /// The Id of the default data element in this context + /// + public DataElementId DataElementId { get; } /// /// Get all children and children of children of this componentContext (not including this) @@ -81,24 +88,4 @@ public IEnumerable Descendants } } } - - // /// - // /// Custom equals that makes sure the component has the same ID and page, and that the shortest RowIndicies match - // /// - // public override bool Equals(object? obj) - // { - // return obj is ComponentContext context && - // context.Component.Id == Component.Id && - // context.Component.PageId == Component.PageId && - // (context.RowIndices?.Zip(RowIndices??Enumerable.Empty()).All((i)=> i.First == i.Second) ?? true); - // } - - // /// - // /// Implement to remove warning when overriding . It is likely never used as a Dictionary Key. - // /// - // public override int GetHashCode() - // { - // // Ignore RowIndicies and ChildContexts - // return HashCode.Combine(Component.PageId, Component.Id); - // } } diff --git a/src/Altinn.App.Core/Models/Layout/DataReference.cs b/src/Altinn.App.Core/Models/Layout/DataReference.cs new file mode 100644 index 000000000..02d9a77e5 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/DataReference.cs @@ -0,0 +1,17 @@ +namespace Altinn.App.Core.Models.Layout; + +/// +/// Represents a reference to a value stored in the data model +/// +public record struct DataReference +{ + /// + /// Reference to a field in the data model, using our standard notation (eg "model.gruppe[0].element") + /// + public required string Field { get; init; } + + /// + /// The Id of the data element that the field is referencing + /// + public required DataElementId DataElementId { get; init; } +} diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 44f3c8fb0..4e231b77e 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -37,7 +37,7 @@ public BaseComponent GetComponent(string pageName, string componentId) } /// - /// Get all components by recursivly walking all the pages. + /// Get all components by recursively walking all the pages. /// public IEnumerable GetComponents() { @@ -53,43 +53,43 @@ public IEnumerable GetComponents() } } - /// - /// Get all external model references used in the layout model - /// - public IEnumerable GetReferencedDataTypeIds() - { - var externalModelReferences = new HashSet(); - foreach (var component in GetComponents()) - { - // Add data model references from DataModelBindings - externalModelReferences.UnionWith( - component.DataModelBindings.Values.Select(d => d.DataType).OfType() - ); - - // Add data model references from expressions - AddExternalModelReferences(component.Hidden, externalModelReferences); - AddExternalModelReferences(component.ReadOnly, externalModelReferences); - AddExternalModelReferences(component.Required, externalModelReferences); - //TODO: add more expressions when backend uses them - } - - //Ensure that the defaultData type is first in the resulting enumerable. - externalModelReferences.Remove(DefaultDataType.Id); - return externalModelReferences.Prepend(DefaultDataType.Id); - } - - private static void AddExternalModelReferences(Expression expression, HashSet externalModelReferences) - { - if ( - expression is - { Function: ExpressionFunction.dataModel, Args: [_, { Value: string externalModelReference }] } - ) - { - externalModelReferences.Add(externalModelReference); - } - else - { - expression.Args?.ForEach(arg => AddExternalModelReferences(arg, externalModelReferences)); - } - } + // /// + // /// Get all external model references used in the layout model + // /// + // public IEnumerable GetReferencedDataTypeIds() + // { + // var externalModelReferences = new HashSet(); + // foreach (var component in GetComponents()) + // { + // // Add data model references from DataModelBindings + // externalModelReferences.UnionWith( + // component.DataModelBindings.Values.Select(d => d.DataType).OfType() + // ); + // + // // Add data model references from expressions + // AddExternalModelReferences(component.Hidden, externalModelReferences); + // AddExternalModelReferences(component.ReadOnly, externalModelReferences); + // AddExternalModelReferences(component.Required, externalModelReferences); + // //TODO: add more expressions when backend uses them + // } + // + // //Ensure that the defaultData type is first in the resulting enumerable. + // externalModelReferences.Remove(DefaultDataType.Id); + // return externalModelReferences.Prepend(DefaultDataType.Id); + // } + // + // private static void AddExternalModelReferences(Expression expression, HashSet externalModelReferences) + // { + // if ( + // expression is + // { Function: ExpressionFunction.dataModel, Args: [_, { Value: string externalModelReference }] } + // ) + // { + // externalModelReferences.Add(externalModelReference); + // } + // else + // { + // expression.Args?.ForEach(arg => AddExternalModelReferences(arg, externalModelReferences)); + // } + // } } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 38d09ead6..ef81fb159 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -9,6 +9,7 @@ using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Validation; using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; @@ -54,9 +55,9 @@ public ExpressionValidatorTests(ITestOutputHelper output) ); } - public ExpressionValidationTestModel LoadData(string fileName, string folder) + public async Task LoadData(string fileName, string folder) { - var data = File.ReadAllText(Path.Join(folder, fileName)); + var data = await File.ReadAllTextAsync(Path.Join(folder, fileName)); return JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; } @@ -76,14 +77,19 @@ public async Task RunExpressionValidationTestsForShared(string fileName, string private async Task RunExpressionValidationTest(string fileName, string folder) { - var testCase = LoadData(fileName, folder); + var testCase = await LoadData(fileName, folder); var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app", }; var dataElement = new DataElement { DataType = "default", }; - var dataModel = DynamicClassBuilder.DataModelFromJsonDocument(testCase.FormData, dataElement); + var dataModel = DynamicClassBuilder.DataModelFromJsonDocument(instance, testCase.FormData, dataElement); - var evaluatorState = new LayoutEvaluatorState(dataModel, testCase.Layouts, _frontendSettings.Value, instance); + var layoutModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default" }, + Pages = testCase.Layouts, + }; + var evaluatorState = new LayoutEvaluatorState(dataModel, layoutModel, _frontendSettings.Value, instance); _layoutInitializer .Setup(init => init.Init( @@ -143,7 +149,7 @@ public record ExpressionValidationTestModel [JsonPropertyName("layouts")] [JsonConverter(typeof(LayoutModelConverterFromObject))] - public required LayoutModel Layouts { get; set; } + public required IReadOnlyDictionary Layouts { get; set; } public class ExpectedObject { diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 7fda8b907..79aa6057e 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -33,6 +33,7 @@ public class ExpressionsExclusiveGatewayTests private const string App = "test"; private const string AppId = $"{Org}/{App}"; private const string TaskId = "Task_1"; + private const string DefaultDataTypeName = "testDefaultModel"; private static readonly string _classRef = typeof(DummyModel).FullName!; public ExpressionsExclusiveGatewayTests() @@ -48,8 +49,8 @@ public async Task FilterAsync_NoExpressions_ReturnsAllFlows() { new() { - Id = "test", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } + Id = DefaultDataTypeName, + AppLogic = new() { ClassRef = _classRef, } } }; @@ -64,11 +65,11 @@ public async Task FilterAsync_NoExpressions_ReturnsAllFlows() { Id = "500000/60226acd-b821-4aae-82cd-97a342071bd3", InstanceOwner = new() { PartyId = "500000" }, - AppId = "ttd/test", - Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, + AppId = AppId, + Process = new() { CurrentTask = new() { ElementId = TaskId } }, Data = new() { - new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "test" } + new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = DefaultDataTypeName } } }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; @@ -92,7 +93,7 @@ public async Task FilterAsync_Expression_filters_based_on_action() { new() { - Id = "test", + Id = DefaultDataTypeName, AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } } }; @@ -107,11 +108,11 @@ public async Task FilterAsync_Expression_filters_based_on_action() { Id = "500000/60226acd-b821-4aae-82cd-97a342071bd3", InstanceOwner = new() { PartyId = "500000" }, - AppId = "ttd/test", - Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, + AppId = AppId, + Process = new() { CurrentTask = new() { ElementId = TaskId } }, Data = new() { - new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "test" } + new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = DefaultDataTypeName } } }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; @@ -134,13 +135,15 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou { new() { - Id = "aa", + Id = "not-found", + TaskId = TaskId, AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.NotFound", } }, new() { - Id = "test", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } + Id = DefaultDataTypeName, + TaskId = TaskId, + AppLogic = new() { ClassRef = _classRef, } } }; object formData = new DummyModel() { Amount = 1000, Submitter = "test" }; @@ -165,11 +168,11 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou { Id = "500000/60226acd-b821-4aae-82cd-97a342071bd3", InstanceOwner = new() { PartyId = "500000" }, - AppId = "ttd/test", - Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, + AppId = AppId, + Process = new() { CurrentTask = new() { ElementId = TaskId } }, Data = new() { - new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "test" } + new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = DefaultDataTypeName } } }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", }; @@ -198,12 +201,12 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew new() { Id = "aa", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } + AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.NotFound", } }, new() { - Id = "test", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.DummyModel", } + Id = DefaultDataTypeName, + AppLogic = new() { ClassRef = _classRef, } } }; @@ -216,7 +219,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew { Id = "test", Tasks = new() { "Task_1" }, - DataType = "test" + DataType = DefaultDataTypeName } } }; @@ -233,7 +236,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew Process = new() { CurrentTask = new() { ElementId = "Task_1" } }, Data = new() { - new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "test" } + new() { Id = "cd9204e7-9b83-41b4-b2f2-9b196b4fafcf", DataType = "aa" } } }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", DataTypeId = "aa" }; @@ -269,7 +272,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew .Returns( new LayoutModel() { - DefaultDataType = new() { Id = "test", }, + DefaultDataType = dataTypes.Single(d => d.Id == DefaultDataTypeName), Pages = new Dictionary() { { @@ -301,6 +304,8 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew ) ) .ReturnsAsync(formData); + + _appModel.Setup(am => am.GetModelType(_classRef)).Returns(formData.GetType()); } var frontendSettings = Options.Create(new FrontEndSettings()); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs index 4b7f235c1..fc94447ee 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; @@ -26,7 +27,7 @@ public class ContextListRoot [JsonPropertyName("layouts")] [JsonConverter(typeof(LayoutModelConverterFromObject))] - public LayoutModel ComponentModel { get; set; } = default!; + public IReadOnlyDictionary Layouts { get; set; } = default!; [JsonPropertyName("dataModel")] public JsonElement? DataModel { get; set; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index b3c353ffc..d69f4460b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -1,8 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; @@ -41,7 +44,7 @@ public class ExpressionTestCaseRoot [JsonPropertyName("layouts")] [JsonConverter(typeof(LayoutModelConverterFromObject))] - public LayoutModel ComponentModel { get; set; } = default!; + public IReadOnlyDictionary Layouts { get; set; } = default!; [JsonPropertyName("dataModel")] public JsonElement? DataModel { get; set; } @@ -53,7 +56,7 @@ public class ExpressionTestCaseRoot public FrontEndSettings? FrontEndSettings { get; set; } [JsonPropertyName("instance")] - public Instance? Instance { get; set; } + public Instance Instance { get; set; } = new Instance(); [JsonPropertyName("gatewayAction")] public string? GatewayAction { get; set; } @@ -97,9 +100,14 @@ public class ComponentContextForTestSpec public IEnumerable ChildContexts { get; set; } = Enumerable.Empty(); - public ComponentContext ToContext(LayoutModel model) + public ComponentContext ToContext(LayoutModel model, LayoutEvaluatorState state) { - return new ComponentContext(model.GetComponent(CurrentPageName, ComponentId), RowIndices, null); + return new ComponentContext( + model.GetComponent(CurrentPageName, ComponentId), + RowIndices, + null, + state.GetDefaultElementId() + ); } public static ComponentContextForTestSpec FromContext(ComponentContext context) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs index b64bed7d0..7e6ecc993 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs @@ -2,7 +2,6 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; -using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; @@ -14,10 +13,14 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; /// standard json parser to convert to an object graph. Using /// directly I can convert to a more suitable C# representation directly /// -public class LayoutModelConverterFromObject : JsonConverter +public class LayoutModelConverterFromObject : JsonConverter> { /// - public override LayoutModel? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override IReadOnlyDictionary? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { if (reader.TokenType != JsonTokenType.StartObject) { @@ -52,15 +55,15 @@ public class LayoutModelConverterFromObject : JsonConverter pages[pageName] = converter.ReadNotNull(ref reader, pageName, options); } - return new LayoutModel() - { - DefaultDataType = new DataType() { Id = "default", }, - Pages = pages - }; + return pages; } /// - public override void Write(Utf8JsonWriter writer, LayoutModel value, JsonSerializerOptions options) + public override void Write( + Utf8JsonWriter writer, + IReadOnlyDictionary value, + JsonSerializerOptions options + ) { throw new NotImplementedException(); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 4c0253c20..5cf50e1d0 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; +using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Xunit.Abstractions; @@ -21,14 +23,14 @@ public TestBackendExclusiveFunctions(ITestOutputHelper output) [Theory] [ExclusiveTest("gatewayAction")] - public void GatewayAction_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task GatewayAction_Theory(string testName, string folder) => await RunTestCase(testName, folder); - private static ExpressionTestCaseRoot LoadTestCase(string testName, string folder) + private async Task LoadTestCase(string testName, string folder) { var file = Path.Join(folder, testName); ExpressionTestCaseRoot testCase = new(); - var data = File.ReadAllText(file); + var data = await File.ReadAllTextAsync(file); try { testCase = JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; @@ -52,15 +54,23 @@ private static ExpressionTestCaseRoot LoadTestCase(string testName, string folde return testCase; } - private void RunTestCase(string testName, string folder) + private async Task RunTestCase(string testName, string folder) { - var test = LoadTestCase(testName, folder); + var test = await LoadTestCase(testName, folder); _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); + var componentModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default", }, + Pages = test.Layouts, + }; var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), - test.ComponentModel, + DynamicClassBuilder.DataModelFromJsonDocument( + test.Instance, + test.DataModel ?? JsonDocument.Parse("{}").RootElement + ), + componentModel, test.FrontEndSettings ?? new(), test.Instance ?? new(), test.GatewayAction, @@ -75,15 +85,15 @@ private void RunTestCase(string testName, string folder) } else { - Action act = () => + Func act = async () => { - ExpressionEvaluator.EvaluateExpression( + await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(test.ComponentModel)! + test.Context?.ToContext(componentModel, state)! ); }; - act.Should().Throw().WithMessage(test.ExpectsFailure); + (await act.Should().ThrowAsync()).WithMessage(test.ExpectsFailure); } return; @@ -91,10 +101,10 @@ private void RunTestCase(string testName, string folder) test.ParsingException.Should().BeNull("Loading of test failed"); - var result = ExpressionEvaluator.EvaluateExpression( + var result = await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(test.ComponentModel)! + test.Context?.ToContext(componentModel, state)! ); switch (test.Expects.ValueKind) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs index f28888b8c..505ae8bcb 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs @@ -1,8 +1,10 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; +using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Xunit.Abstractions; @@ -22,24 +24,24 @@ public TestContextList(ITestOutputHelper output) [Theory] [SharedTestContextList("simple")] - public void Simple_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Simple_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTestContextList("groups")] - public void Group_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Group_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTestContextList("nonRepeatingGroups")] - public void NonRepeatingGroup_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task NonRepeatingGroup_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTestContextList("recursiveGroups")] - public void RecursiveGroup_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task RecursiveGroup_Theory(string testName, string folder) => await RunTestCase(testName, folder); - private static ContextListRoot LoadTestData(string testName, string folder) + private static async Task LoadTestData(string testName, string folder) { ContextListRoot testCase = new(); - var data = File.ReadAllText(Path.Join(folder, testName)); + var data = await File.ReadAllTextAsync(Path.Join(folder, testName)); try { testCase = JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; @@ -56,23 +58,34 @@ private static ContextListRoot LoadTestData(string testName, string folder) return testCase; } - private void RunTestCase(string filename, string folder) + private async Task RunTestCase(string filename, string folder) { - var test = LoadTestData(filename, folder); + var test = await LoadTestData(filename, folder); _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); + var instance = new Instance() { Data = [] }; + var componentModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default" }, + Pages = test.Layouts, + }; var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), - test.ComponentModel, + DynamicClassBuilder.DataModelFromJsonDocument( + instance, + test.DataModel ?? JsonDocument.Parse("{}").RootElement + ), + componentModel, new(), - new() + instance ); test.ParsingException.Should().BeNull("Loading of test failed"); - var results = state.GetComponentContexts().Select(c => ComponentContextForTestSpec.FromContext(c)).ToList(); + var results = (await state.GetComponentContexts()) + .Select(c => ComponentContextForTestSpec.FromContext(c)) + .ToList(); _output.WriteLine(JsonSerializer.Serialize(new { resultContexts = results }, _jsonSerializerOptions)); foreach (var (result, expected, index) in results.Zip(test.Expected, Enumerable.Range(0, int.MaxValue))) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index c02289b6e..dafe201ff 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -1,7 +1,10 @@ using System.Text.Json; +using Altinn.App.Core.Configuration; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; +using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Xunit.Abstractions; @@ -21,116 +24,116 @@ public TestFunctions(ITestOutputHelper output) [Theory] [SharedTest("and")] - public void And_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task And_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("frontendSettings")] - public void FrontendSettings_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task FrontendSettings_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("component")] - public void Component_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Component_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("commaContains")] - public void CommaContains_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task CommaContains_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("concat")] - public void Concat_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Concat_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("language")] - public void Language_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Language_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("contains")] - public void Contains_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Contains_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("dataModel")] - public void DataModel_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task DataModel_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("dataModelMultiple")] - public void DataModelMultiple_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task DataModelMultiple_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("endsWith")] - public void EndsWith_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task EndsWith_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("equals")] - public void Equals_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Equals_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("greaterThan")] - public void GreaterThan_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task GreaterThan_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("greaterThanEq")] - public void GreaterThanEq_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task GreaterThanEq_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("if")] - public void If_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task If_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("not")] - public void Not_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Not_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("notContains")] - public void NotContains_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task NotContains_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("instanceContext")] - public void InstanceContext_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task InstanceContext_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("lessThan")] - public void LessThan_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task LessThan_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("lessThanEq")] - public void LessThanEq_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task LessThanEq_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("notEquals")] - public void NotEquals_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task NotEquals_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("or")] - public void Or_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Or_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("unknown")] - public void Unknown_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Unknown_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("upperCase")] - public void UpperCase_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task UpperCase_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("lowerCase")] - public void LowerCase_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task LowerCase_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("startsWith")] - public void StartsWith_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task StartsWith_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("stringLength")] - public void StringLength_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task StringLength_Theory(string testName, string folder) => await RunTestCase(testName, folder); [Theory] [SharedTest("round")] - public void Round_Theory(string testName, string folder) => RunTestCase(testName, folder); + public async Task Round_Theory(string testName, string folder) => await RunTestCase(testName, folder); - private static ExpressionTestCaseRoot LoadTestCase(string file, string folder) + private static async Task LoadTestCase(string file, string folder) { ExpressionTestCaseRoot testCase = new(); - var data = File.ReadAllText(Path.Join(folder, file)); + var data = await File.ReadAllTextAsync(Path.Join(folder, file)); try { testCase = JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; @@ -153,22 +156,30 @@ private static ExpressionTestCaseRoot LoadTestCase(string file, string folder) return testCase; } - private void RunTestCase(string testName, string folder) + private async Task RunTestCase(string testName, string folder) { - var test = LoadTestCase(testName, folder); + var test = await LoadTestCase(testName, folder); _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); var dataModel = test.DataModels is null - ? DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement) - : DynamicClassBuilder.DataModelFromJsonDocument(test.DataModels); + ? DynamicClassBuilder.DataModelFromJsonDocument( + test.Instance, + test.DataModel ?? JsonDocument.Parse("{}").RootElement + ) + : DynamicClassBuilder.DataModelFromJsonDocument(test.Instance, test.DataModels); + var componentModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default", }, + Pages = test.Layouts, + }; var state = new LayoutEvaluatorState( dataModel, - test.ComponentModel, - test.FrontEndSettings ?? new(), - test.Instance ?? new(), + componentModel, + test.FrontEndSettings ?? new FrontEndSettings(), + test.Instance, test.GatewayAction, test.ProfileSettings?.Language ); @@ -181,15 +192,15 @@ private void RunTestCase(string testName, string folder) } else { - Action act = () => + Func act = async () => { - ExpressionEvaluator.EvaluateExpression( + await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(test.ComponentModel)! + test.Context?.ToContext(componentModel, state)! ); }; - act.Should().Throw().WithMessage(test.ExpectsFailure); + (await act.Should().ThrowAsync()).WithMessage(test.ExpectsFailure); } return; @@ -197,10 +208,10 @@ private void RunTestCase(string testName, string folder) test.ParsingException.Should().BeNull("Loading of test failed"); - var result = ExpressionEvaluator.EvaluateExpression( + var result = await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(test.ComponentModel)! + test.Context?.ToContext(componentModel, state)! ); switch (test.Expects.ValueKind) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index 90bd4db39..f7c30a995 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; +using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Xunit.Abstractions; @@ -21,33 +23,42 @@ public TestInvalid(ITestOutputHelper output) [Theory] [FileNamesInFolderData(["LayoutExpressions", "CommonTests", "shared-tests", "invalid"])] - public void Simple_Theory(string testName, string folder) + public async Task Simple_Theory(string testName, string folder) { - var testCase = LoadData(testName, folder); + var testCase = await LoadData(testName, folder); _output.WriteLine($"{testCase.Filename} in {testCase.Folder}"); _output.WriteLine(testCase.RawJson); _output.WriteLine(testCase.FullPath); - Action act = () => + Func act = async () => { var test = JsonSerializer.Deserialize(testCase.RawJson!, _jsonSerializerOptions)!; + var componentModel = new LayoutModel() + { + DefaultDataType = new DataType() { Id = "default", }, + Pages = test.Layouts, + }; + var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument(test.DataModel ?? JsonDocument.Parse("{}").RootElement), - test.ComponentModel, + DynamicClassBuilder.DataModelFromJsonDocument( + test.Instance, + test.DataModel ?? JsonDocument.Parse("{}").RootElement + ), + componentModel, test.FrontEndSettings ?? new(), test.Instance ?? new() ); - ExpressionEvaluator.EvaluateExpression( + await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(test.ComponentModel) ?? null! + test.Context?.ToContext(componentModel, state) ?? null! ); }; - act.Should().Throw().WithMessage(testCase.ExpectsFailure); + (await act.Should().ThrowAsync()).WithMessage(testCase.ExpectsFailure); } - private static InvalidTestCase LoadData(string testName, string folder) + private static async Task LoadData(string testName, string folder) { - var data = File.ReadAllText(Path.Join(folder, testName)); + var data = await File.ReadAllTextAsync(Path.Join(folder, testName)); using var document = JsonDocument.Parse(data); return new InvalidTestCase() { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json index c11813c86..d04a3e63f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/array-is-null.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json index 01e2cbcc5..77152254f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-group.json @@ -54,7 +54,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json index c07e25a54..5a8a23227 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group.json @@ -69,7 +69,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json index 82eba95cc..505bf761f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json @@ -69,7 +69,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json index b11d5fd92..1dbed8776 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json @@ -69,7 +69,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json index abd271cda..56b7ba698 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json @@ -69,7 +69,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json index 265789ec6..1667df461 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-group.json @@ -54,7 +54,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json index 40e1c2970..3573d6274 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/in-nested-group.json @@ -69,7 +69,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json index cec55487e..ccef08e84 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null-is-null.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json index ea5707692..e5dc1cd4e 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/null.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json index 2302446c0..93aa5ed68 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/object-is-null.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json index 6f52e19a6..2725bf60a 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-equals.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json index 4c44d6220..a1de564cb 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json index 1ebed41a2..8491299b2 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup-is-null2.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json index 004f25aa7..c1c574ba7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModel/simple-lookup.json @@ -5,7 +5,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json index ba6f665e7..ae4c2beb1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json @@ -8,7 +8,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { @@ -19,7 +19,7 @@ }, { "dataElement": { - "id": "123", + "id": "10dd7417-5b4e-402a-bb73-007537071f13", "dataType": "non-default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json index 890e133c0..2b1221af7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json @@ -8,7 +8,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { @@ -19,7 +19,7 @@ }, { "dataElement": { - "id": "123", + "id": "10dd7417-5b4e-402a-bb73-007537071f13", "dataType": "non-default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json index 412ae68b7..3c7ab2be9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json @@ -9,7 +9,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { @@ -20,7 +20,7 @@ }, { "dataElement": { - "id": "123", + "id": "10dd7417-5b4e-402a-bb73-007537071f13", "dataType": "non-defualt" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json index 41bef2618..407cbc59f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json @@ -9,7 +9,7 @@ "dataModels": [ { "dataElement": { - "id": "345", + "id": "00dd7417-5b4e-402a-bb73-007537071f1d", "dataType": "default" }, "data": { @@ -20,7 +20,7 @@ }, { "dataElement": { - "id": "123", + "id": "10dd7417-5b4e-402a-bb73-007537071f13", "dataType": "non-default" }, "data": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 750be0051..5e1f5e6af 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -50,9 +51,15 @@ public static class LayoutTestUtils AppId = AppId, Org = Org, InstanceOwner = new() { PartyId = InstanceOwnerPartyId.ToString() }, - Data = [new DataElement() { Id = _dataGuid.ToString(), DataType = "default", }] + Data = [] }; + private static readonly DataElement _dataElement = new DataElement() + { + Id = _dataGuid.ToString(), + DataType = "default", + }; + public static async Task GetLayoutModelTools(object model, string folder) { var services = new ServiceCollection(); @@ -90,8 +97,8 @@ public static async Task GetLayoutModelTools(object model, resources.Setup(r => r.GetLayoutModelForTask(TaskId)).Returns(layoutModel); services.AddSingleton(resources.Object); - services.AddSingleton(appMetadata.Object); - services.AddSingleton(appModel.Object); + // services.AddSingleton(appMetadata.Object); + // services.AddSingleton(appModel.Object); services.AddScoped(); services.AddOptions().Configure(fes => fes.Add("test", "value")); @@ -100,8 +107,7 @@ public static async Task GetLayoutModelTools(object model, using var scope = serviceProvider.CreateScope(); var initializer = scope.ServiceProvider.GetRequiredService(); - var dataAccessor = new CachedInstanceDataAccessor(_instance, data.Object, appMetadata.Object, appModel.Object); - dataAccessor.Set(_instance.Data[0], model); + var dataAccessor = new TestInstanceDataAccessor(_instance) { { _dataElement, data } }; return await initializer.Init(_instance, dataAccessor, TaskId); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index a7a692b57..76063117c 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -8,13 +8,13 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test1; public class RunTest1 { - [Fact] - public async Task ValidateDataModel() - { - var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test1"); - var errors = state.GetModelErrors(); - errors.Should().BeEmpty(); - } + // [Fact] + // public async Task ValidateDataModel() + // { + // var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test1"); + // var errors = state.GetModelErrors(); + // errors.Should().BeEmpty(); + // } [Fact] public async Task DoNotRemoveAnyData_WhenPageExpressionIsFalse() @@ -29,7 +29,7 @@ public async Task DoNotRemoveAnyData_WhenPageExpressionIsFalse() }, "Test1" ); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); hidden.Should().BeEmpty(); } @@ -46,7 +46,7 @@ public async Task RemoveData_WhenPageExpressionIsTrue() }, "Test1" ); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data.binding2", DataType = "default" }]); } @@ -63,7 +63,7 @@ public async Task RunLayoutValidationsForRequired_InvalidComponentHidden_Returns }, "Test1" ); - var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state); + var validationIssues = await LayoutEvaluator.RunLayoutValidationsForRequired(state); validationIssues.Should().BeEmpty(); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index 92c8436f1..c0900d9f5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -8,13 +8,13 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test2; public class RunTest2 { - [Fact] - public async Task ValidateDataModel() - { - var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test2"); - var errors = state.GetModelErrors(); - errors.Should().BeEmpty(); - } + // [Fact] + // public async Task ValidateDataModel() + // { + // var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test2"); + // var errors = state.GetModelErrors(); + // errors.Should().BeEmpty(); + // } [Fact] public async Task RemoveWholeGroup() @@ -42,7 +42,7 @@ public async Task RemoveWholeGroup() } }; var state = await LayoutTestUtils.GetLayoutModelTools(data, "Test2"); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists hidden @@ -60,7 +60,7 @@ public async Task RemoveWholeGroup() data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero data.Some.Data[1].Binding.Should().Be("binding"); data.Some.Data[1].Binding2.Should().Be(2); - LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.SetToNull); + await LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.DeleteRow); // Verify data was removed data.Some.Data[0].Binding.Should().BeNull(); @@ -87,7 +87,7 @@ public async Task RemoveSingleRow() }, "Test2" ); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); hidden.Should().BeEquivalentTo([new ModelBinding() { Field = "some.data[1].binding2", DataType = "default" }]); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index 5c596a221..df5bcf2cd 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -8,13 +8,13 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test3; public class RunTest3 { - [Fact] - public async Task ValidateDataModel() - { - var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test3"); - var errors = state.GetModelErrors(); - errors.Should().BeEmpty(); - } + // [Fact] + // public async Task ValidateDataModel() + // { + // var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test3"); + // var errors = state.GetModelErrors(); + // errors.Should().BeEmpty(); + // } [Fact] public async Task RemoveRowDataFromGroup() @@ -48,7 +48,7 @@ public async Task RemoveRowDataFromGroup() } }; var state = await LayoutTestUtils.GetLayoutModelTools(data, "Test3"); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); @@ -62,7 +62,7 @@ public async Task RemoveRowDataFromGroup() data.Some.Data[2].Binding.Should().Be("hideRow"); data.Some.Data[2].Binding2.Should().Be(3); data.Some.Data[2].Binding3.Should().Be("text"); - LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.SetToNull); + await LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.DeleteRow); // Verify row not deleted but fields null data.Some.Data.Should().HaveCount(3); @@ -105,7 +105,7 @@ public async Task RemoveRowFromGroup() } }; var state = await LayoutTestUtils.GetLayoutModelTools(data, "Test3"); - var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); @@ -121,7 +121,7 @@ public async Task RemoveRowFromGroup() data.Some.Data[2].Binding3.Should().Be("text"); // Verify rows deleted - LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.DeleteRow); + await LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.DeleteRow); data.Some.Data.Should().HaveCount(2); data.Some.Data[0].Binding.Should().BeNull(); data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs index 130b04a75..070429d00 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs @@ -18,7 +18,7 @@ public class TestDataModel public void TestSimpleGet() { var model = new Model { Name = new() { Value = "myValue" } }; - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, (object)model)]); + var modelHelper = new DataModelWrapper(model); modelHelper.GetModelData("does.not.exist").Should().BeNull(); modelHelper.GetModelData("name.value").Should().Be(model.Name.Value); modelHelper.GetModelData("name.value", [1, 2, 3]).Should().Be(model.Name.Value); @@ -28,7 +28,7 @@ public void TestSimpleGet() public void AttributeNoAttriubteCaseSensitive() { var model = new Model { NoAttribute = "asdfsf559" }; - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, (object)model)]); + var modelHelper = new DataModelWrapper(model); modelHelper.GetModelData("NOATTRIBUTE").Should().BeNull("data model lookup is case sensitive"); modelHelper.GetModelData("noAttribute").Should().BeNull(); modelHelper.GetModelData("NoAttribute").Should().Be("asdfsf559"); @@ -37,9 +37,7 @@ public void AttributeNoAttriubteCaseSensitive() [Fact] public void NewtonsoftAttributeWorks() { - var modelHelper = new DataModel( - [KeyValuePair.Create(_dataElement, (object)new Model { OnlyNewtonsoft = "asdfsf559", })] - ); + var modelHelper = new DataModelWrapper(new Model { OnlyNewtonsoft = "asdfsf559", }); modelHelper.GetModelData("OnlyNewtonsoft").Should().BeNull("Attribute should win over property when set"); modelHelper.GetModelData("ONlyNewtonsoft").Should().BeNull(); modelHelper.GetModelData("onlyNewtonsoft").Should().Be("asdfsf559"); @@ -48,9 +46,7 @@ public void NewtonsoftAttributeWorks() [Fact] public void SystemTextJsonAttributeWorks() { - var modelHelper = new DataModel( - [KeyValuePair.Create(_dataElement, new Model { OnlySystemTextJson = "asdfsf559" })] - ); + var modelHelper = new DataModelWrapper(new Model { OnlySystemTextJson = "asdfsf559" }); modelHelper.GetModelData("OnlySystemTextJson").Should().BeNull("Attribute should win over property when set"); modelHelper.GetModelData("onlysystemtextjson").Should().BeNull(); modelHelper.GetModelData("onlySystemTextJson").Should().Be("asdfsf559"); @@ -71,7 +67,7 @@ public void RecursiveLookup() new() { Name = new() { Value = "Dolly Duck" } } } }; - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); + var modelHelper = new DataModelWrapper(model); modelHelper.GetModelData("friends.name.value").Should().BeNull(); modelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); modelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); @@ -82,7 +78,7 @@ public void RecursiveLookup() // Run the same tests with JsonDataModel using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); - var jsonModelHelper = DynamicClassBuilder.DataModelFromJsonDocument(doc.RootElement); + var jsonModelHelper = new DataModelWrapper(DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement)); jsonModelHelper.GetModelData("friends.name.value").Should().BeNull(); jsonModelHelper.GetModelData("friends[0].name.value").Should().Be("Donald Duck"); jsonModelHelper.GetModelData("friends.name.value", [0]).Should().Be("Donald Duck"); @@ -153,7 +149,7 @@ public void DoubleRecursiveLookup() } }; - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); + var modelHelper = new DataModelWrapper(model); modelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); modelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); modelHelper @@ -171,7 +167,7 @@ public void DoubleRecursiveLookup() // Run the same tests with JsonDataModel using var doc = JsonDocument.Parse(JsonSerializer.Serialize(model)); - var jsonModelHelper = DynamicClassBuilder.DataModelFromJsonDocument(doc.RootElement); + var jsonModelHelper = new DataModelWrapper(DynamicClassBuilder.DataObjectFromJsonDocument(doc.RootElement)); jsonModelHelper.GetModelData("friends[1].friends[0].name.value").Should().Be("Onkel Skrue"); jsonModelHelper.GetModelData("friends[1].friends.name.value", [0, 0]).Should().BeNull(); jsonModelHelper @@ -212,7 +208,7 @@ public void TestRemoveFields() } } }; - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, model)]); + var modelHelper = new DataModelWrapper(model); model.Id.Should().Be(2); modelHelper.RemoveField("id", RowRemovalOption.SetToNull); model.Id.Should().Be(default); @@ -297,7 +293,7 @@ public void TestRemoveRows() // deleteRows = false var model1 = JsonSerializer.Deserialize(serializedModel)!; - var modelHelper1 = new DataModel([KeyValuePair.Create(_dataElement, model1)]); + var modelHelper1 = new DataModelWrapper(model1); modelHelper1.RemoveField("friends[0].friends[0]", RowRemovalOption.SetToNull); model1.Friends![0].Friends![0].Should().BeNull(); @@ -311,7 +307,7 @@ public void TestRemoveRows() // deleteRows = true var model2 = JsonSerializer.Deserialize(serializedModel)!; - var modelHelper2 = new DataModel([KeyValuePair.Create(_dataElement, model2)]); + var modelHelper2 = new DataModelWrapper(model2); modelHelper2.RemoveField("friends[0].friends[0]", RowRemovalOption.DeleteRow); model2.Friends![0].Friends!.Count.Should().Be(2); @@ -325,17 +321,12 @@ public void TestRemoveRows() [Fact] public void TestErrorCases() { - var modelHelper = new DataModel( - [ - KeyValuePair.Create( - _dataElement, - new Model() - { - Id = 3, - Friends = new List() { new() { Name = new() { Value = "Ole" }, } } - } - ) - ] + var modelHelper = new DataModelWrapper( + new Model() + { + Id = 3, + Friends = new List() { new() { Name = new() { Value = "Ole" }, } } + } ); modelHelper.Invoking(m => m.GetModelData(".")).Should().Throw().WithMessage("*empty part*"); modelHelper.GetModelData("friends[0]").Should().BeOfType().Which.Name?.Value.Should().Be("Ole"); @@ -358,17 +349,12 @@ public void TestErrorCases() public void TestEdgeCaseWithNonGenericEnumerableForCoverage() { // Test with erroneous model with non-generic IEnumerable (special error for code coverage) - var modelHelper = new DataModel( - [ - KeyValuePair.Create( - _dataElement, - new - { - // ArrayList is not supported as a data model - friends = new ArrayList { 1, 2, 3 } - } - ) - ] + var modelHelper = new DataModelWrapper( + new + { + // ArrayList is not supported as a data model + friends = new ArrayList { 1, 2, 3 } + } ); modelHelper .Invoking(m => m.AddIndicies("friends", [0])) @@ -380,38 +366,31 @@ public void TestEdgeCaseWithNonGenericEnumerableForCoverage() [Fact] public void TestAddIndicies() { - var modelHelper = new DataModel( - [ - KeyValuePair.Create( - _dataElement, - new Model - { - Id = 3, - Friends = new List() { new() { Name = new() { Value = "Ole" }, } } - } - ) - ] + var modelHelper = new DataModelWrapper( + new Model + { + Id = 3, + Friends = new List() { new() { Name = new() { Value = "Ole" }, } } + } ); // Plain add indicies - modelHelper.AddIndicies("friends.friends", [0, 1]).Field.Should().Be("friends[0].friends[1]"); + modelHelper.AddIndicies("friends.friends", [0, 1]).Should().Be("friends[0].friends[1]"); // Ignore extra indicies - modelHelper.AddIndicies("friends.friends", [0, 1, 4, 6]).Field.Should().Be("friends[0].friends[1]"); + modelHelper.AddIndicies("friends.friends", [0, 1, 4, 6]).Should().Be("friends[0].friends[1]"); // Don't add indicies if they are specified in input - modelHelper.AddIndicies("friends[3]", [0]).Field.Should().Be("friends[3]"); + modelHelper.AddIndicies("friends[3]", [0]).Should().Be("friends[3]"); // First index is ignored if it is explicit - modelHelper.AddIndicies("friends[0].friends", [2, 3]).Field.Should().Be("friends[0].friends[3]"); + modelHelper.AddIndicies("friends[0].friends", [2, 3]).Should().Be("friends[0].friends[3]"); } [Fact] public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() { - var modelHelper = new DataModel( - [KeyValuePair.Create(_dataElement, new Model { Id = 3, })] - ); + var modelHelper = new DataModelWrapper(new Model { Id = 3, }); // Throws because id is not indexable modelHelper @@ -424,7 +403,7 @@ public void AddIndicies_WhenGivenIndexOnNonIndexableProperty_ThrowsError() [Fact] public void RemoveField_WhenValueDoesNotExist_DoNothing() { - var modelHelper = new DataModel([KeyValuePair.Create(_dataElement, new Model())]); + var modelHelper = new DataModelWrapper(new Model()); // real fields works, no error modelHelper.RemoveField("id", RowRemovalOption.SetToNull); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs index c0791ef16..bfcbf90be 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs @@ -2,6 +2,7 @@ using System.Reflection.Emit; using System.Text.Json; using System.Text.Json.Serialization; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; using Altinn.Platform.Storage.Interface.Models; @@ -123,7 +124,7 @@ private static Type GetArrayType(JsonElement arrayElement, string propertyName, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, }; - private static object DataObjectFromJsonDocument(JsonElement doc) + public static object DataObjectFromJsonDocument(JsonElement doc) { var type = CreateClassFromJsonElement(doc, "DynamicClass"); @@ -134,20 +135,25 @@ private static object DataObjectFromJsonDocument(JsonElement doc) return instance; } - public static DataModel DataModelFromJsonDocument(JsonElement doc, DataElement? dataElement = null) + public static DataModel DataModelFromJsonDocument( + Instance instance, + JsonElement doc, + DataElement? dataElement = null + ) { - object instance = DataObjectFromJsonDocument(doc); - return new DataModel( - [KeyValuePair.Create(dataElement ?? new DataElement() { DataType = "default" }, instance)] - ); + object data = DataObjectFromJsonDocument(doc); + var dataAccessor = new TestInstanceDataAccessor(instance) { { dataElement, data } }; + return new DataModel(dataAccessor); } - public static DataModel DataModelFromJsonDocument(List dataModels) + public static DataModel DataModelFromJsonDocument(Instance instance, List dataModels) { - return new DataModel( - dataModels.Select(dataModel => - KeyValuePair.Create(dataModel.DataElement, DataObjectFromJsonDocument(dataModel.Data)) - ) - ); + var dataAccessor = new TestInstanceDataAccessor(instance); + foreach (var pair in dataModels) + { + dataAccessor.Add(pair.DataElement, DataObjectFromJsonDocument(pair.Data)); + } + + return new DataModel(dataAccessor); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs new file mode 100644 index 000000000..8fd146289 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs @@ -0,0 +1,55 @@ +using System.Collections; +using Altinn.App.Core.Features; +using Altinn.App.Core.Models; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; + +public class TestInstanceDataAccessor : IInstanceDataAccessor, IEnumerable> +{ + public TestInstanceDataAccessor(Instance instance) + { + Instance = instance; + Instance.Data ??= new(); + } + + private readonly Dictionary _dataById = new(); + private readonly Dictionary _dataByType = new(); + + public void Add(DataElement? dataElement, object data) + { + dataElement ??= new DataElement(); + dataElement.DataType ??= "default"; + dataElement.Id ??= Guid.NewGuid().ToString(); + if (!Instance.Data.Contains(dataElement)) + { + Instance.Data.Add(dataElement); + } + + _dataById.Add(dataElement, data); + _dataByType.TryAdd(dataElement.DataType, data); + } + + public Instance Instance { get; } + + public Task GetData(DataElementId dataElementId) + { + return Task.FromResult(_dataById[dataElementId]); + } + + public Task GetSingleDataByType(string dataType) + { + return Task.FromResult(_dataByType.GetValueOrDefault(dataType)); + } + + public IEnumerator> GetEnumerator() + { + // We implement IEnumerable so that we can use the collection initializer syntax + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs b/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs new file mode 100644 index 000000000..c5d9f4e13 --- /dev/null +++ b/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Tests.TestUtils; + +public class ServiceProviderTests +{ + private readonly IServiceCollection _serviceProvider = new ServiceCollection(); + + protected class ParentService(IServiceProvider serviceProvider) + { + public SingletonService SingletonService => serviceProvider.GetRequiredService(); + public ScopedService ScopedService => serviceProvider.GetRequiredService(); + } + + protected class ScopedService(IServiceProvider serviceProvider) : ParentService(serviceProvider) { } + + protected class SingletonService(IServiceProvider serviceProvider) : ParentService(serviceProvider) { } + + public ServiceProviderTests() + { + _serviceProvider.AddSingleton(); + _serviceProvider.AddScoped(); + } + + [Fact] + public void TestServices() + { + using var serviceProvider = _serviceProvider.BuildServiceProvider( + new ServiceProviderOptions() { ValidateScopes = true, ValidateOnBuild = true } + ); + // var singletonService = serviceProvider.GetRequiredService(); + // var scopedRootService = singletonService.ScopedService; + + + using var scope = serviceProvider.CreateScope(); + var scopedService = scope.ServiceProvider.GetRequiredService(); + scopedService.Should().NotBeNull(); + scopedService.ScopedService.Should().Be(scopedService); + + using var scope2 = serviceProvider.CreateScope(); + var scopedService2 = scope2.ServiceProvider.GetRequiredService(); + scopedService2.Should().NotBe(scopedService); + scopedService2.ScopedService.Should().Be(scopedService2); + var action = () => scopedService.SingletonService.ScopedService; + action.Should().Throw(); + } +} diff --git a/test/Suggestions for DI scope issue.md b/test/Suggestions for DI scope issue.md new file mode 100644 index 000000000..7b7fb393f --- /dev/null +++ b/test/Suggestions for DI scope issue.md @@ -0,0 +1,28 @@ +## Issue description +Service owner had an implementation of `IInstantiationProcessor` that was registered as a Transient service, and used a class variable to store the user object from IHttpContextAccessor. +This would be fine if our code actually resolved the implementation in a transient context. +An error in our code caused IInstanceProcessor to be resolved as a dependency from a Singleton service, and thus only one instance was ever created. +This caused the class variable to be shared between all requests, and thus the user object to be shared between all requests, leaking the information from the first user that created an instance to all subsequent forms until the app was restarted in kubernetes. + +## Immediate actions + +### Go through all apps to see if any appears to have a similar issue with the . +See if we need to alert other service owners (not sure if Martin regards this as complete) + +### Go through all Singleton services to ensure that they can really be singleton +This would have detected our error, and might find other similar issues (but is a one time job, not fixing things for the future) + +## Future actions to reduce the risk of similar issues. + +### Use IServiceProvider instead of injecting hook interfaces directly where they are used +This will have multiple benefits in addition to make Transient services (actually transient) when used from a Singleton service +* One class that wraps `IServiceProvider` for all our "officially supported" hook interfaces makes it obvious (in code) which interfaces are regarded as extension points and makes it easier to ensure consistent patterns and telemetry. +* Constructors for service implementations (that might be slow for /wrong/ code) will only run when actually required (not all calls to other endpoints on controller) +* We will have the ability to create a telemetry span for the `services.GetServices` call, highlighting slow constructors. + +### Ensure(Verify) that apps by default runs with verifyScopes +Scoped services might otherwise inherit the root (singleton) scope, so this validation might help us catch issues. + +### (Probably not) Suggest in documentation that hook interfaces gets registered with "AddScoped" +Will ensure that a similar issue will gets detected if the app DI runs with verifyScopes. +But if verifyScopes is off, this will cause cross request reuse if the service is resolved in a singleton context. From 45d21080e19bef06fccec7d505195b170f780d18 Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Tue, 3 Sep 2024 12:50:20 +0200 Subject: [PATCH 17/63] Introduces `LayoutSet.Type` and adds null checks for `LayoutSet.Tasks` --- src/Altinn.App.Api/Controllers/PdfController.cs | 2 +- src/Altinn.App.Core/Implementation/AppResourcesSI.cs | 2 +- .../Internal/Process/ExpressionsExclusiveGateway.cs | 2 +- src/Altinn.App.Core/Models/LayoutSet.cs | 8 +++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/PdfController.cs b/src/Altinn.App.Api/Controllers/PdfController.cs index 433849140..174a58353 100644 --- a/src/Altinn.App.Api/Controllers/PdfController.cs +++ b/src/Altinn.App.Api/Controllers/PdfController.cs @@ -132,7 +132,7 @@ [FromRoute] Guid dataGuid JsonSerializer.Deserialize(layoutSetsString, _jsonSerializerOptions) ?? throw new JsonException("Could not deserialize LayoutSets"); layoutSet = layoutSets.Sets?.FirstOrDefault(t => - t.DataType.Equals(dataElement.DataType, StringComparison.Ordinal) && t.Tasks.Contains(taskId) + t.DataType.Equals(dataElement.DataType, StringComparison.Ordinal) && t.Tasks?.Contains(taskId) is true ); } diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 5adb31c4f..4c6e8e002 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -286,7 +286,7 @@ public string GetLayoutSets() { using var activity = _telemetry?.StartGetLayoutSetsForTaskActivity(); var sets = GetLayoutSet(); - return sets?.Sets?.FirstOrDefault(s => s?.Tasks?.Contains(taskId) ?? false); + return sets?.Sets?.FirstOrDefault(s => s.Tasks?.Contains(taskId) is true); } /// diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 35bfe198d..13b925579 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -155,7 +155,7 @@ private static Expression GetExpressionFromCondition(string condition) layoutSetsString, _jsonSerializerOptionsCamelCase ); - layoutSet = layoutSets?.Sets?.Find(t => t.Tasks.Contains(taskId)); + layoutSet = layoutSets?.Sets?.Find(t => t.Tasks?.Contains(taskId) is true); } return layoutSet; diff --git a/src/Altinn.App.Core/Models/LayoutSet.cs b/src/Altinn.App.Core/Models/LayoutSet.cs index 8ac2a49cc..78e1f11ae 100644 --- a/src/Altinn.App.Core/Models/LayoutSet.cs +++ b/src/Altinn.App.Core/Models/LayoutSet.cs @@ -17,8 +17,14 @@ public class LayoutSet public string DataType { get; set; } /// - /// List of tasks where layuout should be used + /// List of tasks where layout should be used /// public List Tasks { get; set; } + #nullable restore + + /// + /// The type description for the layout + /// + public string? Type { get; set; } } From e36b6cb8778276d6eb5ed5831ab67f499cbf1ad8 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 06:50:50 +0200 Subject: [PATCH 18/63] Almost make the tests pass --- .../Validation/Default/ExpressionValidator.cs | 1 - .../Validation/Default/RequiredValidator.cs | 1 - .../Helpers/DataModel/DataModel.cs | 8 +- .../Internal/Data/CachedFormDataAccessor.cs | 2 +- .../Expressions/ExpressionEvaluator.cs | 15 +- .../ILayoutEvaluatorStateInitializer.cs | 3 +- .../Internal/Expressions/LayoutEvaluator.cs | 66 +++---- .../Expressions/LayoutEvaluatorState.cs | 184 ++++++++---------- .../LayoutEvaluatorStateInitializer.cs | 11 +- .../Process/ExpressionsExclusiveGateway.cs | 65 ++++--- .../Common/ProcessTaskFinalizer.cs | 1 - .../Models/Expressions/ComponentContext.cs | 68 ++++++- .../Controllers/DataController_PatchTests.cs | 4 +- .../Default/ExpressionValidatorTests.cs | 24 +-- .../ExpressionsExclusiveGatewayTests.cs | 83 +++----- .../CommonTests/ExpressionTestCaseRoot.cs | 12 +- .../CommonTests/TestFunctions.cs | 3 +- .../component-lookup-non-existant-model.json | 2 +- .../dataModel-non-existing-model.json | 2 +- .../FullTests/LayoutTestUtils.cs | 9 +- .../FullTests/Test1/RunTest1.cs | 8 +- .../FullTests/Test2/RunTest2.cs | 16 +- .../FullTests/Test3/RunTest3.cs | 14 +- 23 files changed, 294 insertions(+), 308 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 55cb02739..9c3744095 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -101,7 +101,6 @@ internal async Task> ValidateFormData( using var validationConfig = JsonDocument.Parse(rawValidationConfig); var evaluatorState = await _layoutEvaluatorStateInitializer.Init( - instance, dataAccessor, taskId, gatewayAction: null, diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index 1dcf45162..9d3375514 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -38,7 +38,6 @@ public async Task> Validate( ) { var evaluationState = await _layoutEvaluatorStateInitializer.Init( - instance, instanceDataAccessor, taskId, gatewayAction: null, diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 617e91129..5a3962454 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -93,7 +93,7 @@ public async Task GetResolvedKeys(DataReference reference) private static readonly Regex _rowIndexRegex = new Regex( @"^([^[\]]+(\[(\d+)])?)+$", RegexOptions.None, - TimeSpan.FromMilliseconds(1) + TimeSpan.FromMilliseconds(10) ); /// @@ -114,11 +114,11 @@ public async Task GetResolvedKeys(DataReference reference) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public async Task AddIndexes(ModelBinding key, DataElementId dataElementId, int[]? rowIndexes) + public async Task AddIndexes(ModelBinding key, DataElementId dataElementId, int[]? rowIndexes) { if (rowIndexes?.Length < 0) { - return key; + return new DataReference() { Field = key.Field, DataElementId = dataElementId }; } var serviceModel = await ServiceModel(key, dataElementId); if (serviceModel is null) @@ -128,7 +128,7 @@ public async Task AddIndexes(ModelBinding key, DataElementId dataE var modelWrapper = new DataModelWrapper(serviceModel); var field = modelWrapper.AddIndicies(key.Field, rowIndexes); - return key with { Field = field }; + return new DataReference() { Field = field, DataElementId = dataElementId }; } /// diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index c7baa85e9..bf059e453 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -80,7 +80,7 @@ public async Task GetData(DataElementId dataElementId) if (dataType == null) { throw new InvalidOperationException( - $"Data type {dataElementType ?? "null"} for data element id {dataElementId} not found in app metadata" + $"Data type {dataElementType ?? "unknown"} for data element id {dataElementId} not found in app metadata" ); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 712af7f02..53ee2fc54 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -164,6 +164,11 @@ LayoutEvaluatorState state context.RowIndices ); + if (targetContext is null) + { + return null; + } + if (targetContext.Component is GroupComponent) { throw new NotImplementedException("Component lookup for components in groups not implemented"); @@ -173,15 +178,9 @@ LayoutEvaluatorState state { throw new ArgumentException("component lookup requires the target component to have a simpleBinding"); } - ComponentContext? parent = targetContext; - while (parent is not null) + if (await targetContext.IsHidden(state)) { - if (await EvaluateBooleanExpression(state, parent, "hidden", false)) - { - // Don't lookup data in hidden components - return null; - } - parent = parent.Parent; + return null; } return await DataModel(binding, context.DataElementId, context.RowIndices, state); diff --git a/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs index 408bdc736..7dacbf355 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ILayoutEvaluatorStateInitializer.cs @@ -14,9 +14,8 @@ public interface ILayoutEvaluatorStateInitializer /// The remaining data will be fetched from dependency injection services /// Task Init( - Instance instance, IInstanceDataAccessor dataAccessor, - string taskId, + string? taskId, string? gatewayAction = null, string? language = null ); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index b6a173d07..0c44525bb 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -1,6 +1,4 @@ -using System.Diagnostics; using Altinn.App.Core.Helpers.DataModel; -using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -59,10 +57,11 @@ ComponentContext context foreach (var childContext in context.ChildContexts) { // Ignore children of hidden rows if includeHiddenRowChildren is false - if (!includeHiddenRowChildren && context.HiddenRows is not null) + if (!includeHiddenRowChildren && context.Component is RepeatingGroupComponent) { - var currentRow = childContext.RowIndices?.Last(); - var rowIsHidden = currentRow is not null && context.HiddenRows.Contains(currentRow.Value); + var hiddenRows = await childContext.GetHiddenRows(state); + var currentRow = childContext.RowIndices?[^1]; + var rowIsHidden = currentRow is not null && hiddenRows[currentRow.Value]; if (rowIsHidden) { continue; @@ -78,36 +77,27 @@ await HiddenFieldsForRemovalRecurs( ); } - // Remove data for hidden rows - if ( - context.Component is RepeatingGroupComponent repGroup - && context.RowLength is not null - && context.HiddenRows is not null - ) + // Get dataReference for hidden rows + if (context is { Component: RepeatingGroupComponent repGroup }) { - foreach (var index in Enumerable.Range(0, context.RowLength.Value).Reverse()) + var hiddenRows = await context.GetHiddenRows(state); + // Reverse order to get the last hidden row first so that the index is correct when removing from the data object + foreach (var index in Enumerable.Range(0, hiddenRows.Length).Reverse()) { var rowIndices = context.RowIndices?.Append(index).ToArray() ?? [index]; - var newContext = new ComponentContext( - context.Component, - rowIndices, - rowLength: null, - dataElementId: context.DataElementId, - childContexts: context.ChildContexts + var indexedBinding = await state.AddInidicies( + repGroup.DataModelBindings["group"], + context.DataElementId, + rowIndices ); - var indexedBinding = await state.AddInidicies(repGroup.DataModelBindings["group"], newContext); - var fieldReference = new DataReference() - { - Field = indexedBinding.Field, - DataElementId = newContext.DataElementId - }; - if (context.HiddenRows.Contains(index)) + + if (hiddenRows[index]) { - hiddenModelBindings.Add(fieldReference); + hiddenModelBindings.Add(indexedBinding); } else { - nonHiddenModelBindings.Add(fieldReference); + nonHiddenModelBindings.Add(indexedBinding); } } } @@ -123,19 +113,15 @@ context.Component is RepeatingGroupComponent repGroup } var indexedBinding = await state.AddInidicies(binding, context); - var fieldReference = new DataReference() - { - Field = indexedBinding.Field, - DataElementId = context.DataElementId - }; + var isHidden = await context.IsHidden(state); - if (context.IsHidden == true) + if (isHidden) { - hiddenModelBindings.Add(fieldReference); + hiddenModelBindings.Add(indexedBinding); } else { - nonHiddenModelBindings.Add(fieldReference); + nonHiddenModelBindings.Add(indexedBinding); } } } @@ -174,7 +160,8 @@ private static async Task RunLayoutValidationsForRequiredRecurs( ComponentContext context ) { - if (context.IsHidden == false) + var hidden = await context.IsHidden(state); + if (!hidden) { foreach (var childContext in context.ChildContexts) { @@ -189,15 +176,14 @@ ComponentContext context if (await state.GetModelData(binding, context.DataElementId, context.RowIndices) is null) { var field = await state.AddInidicies(binding, context); - DataElementId dataElementId = context.DataElementId; - if (field.DataType is not null) { } validationIssues.Add( new ValidationIssue() { Severity = ValidationIssueSeverity.Error, - DataElementId = dataElementId.ToString(), + DataElementId = field.DataElementId.ToString(), Field = field.Field, - Description = $"{field.Field} is required in component with id {context.Component.Id}", + Description = + $"{field.Field} is required in component with id {context.Component.Id} for binding {bindingName}", Code = "required", } ); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index e4d2927d8..12e90e5b7 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,3 +1,4 @@ +using System.Collections; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models; @@ -15,18 +16,18 @@ public class LayoutEvaluatorState { private readonly DataModel _dataModel; private readonly LayoutModel? _componentModel; - private readonly DataElementId _defaultDataElementId; private readonly FrontEndSettings _frontEndSettings; private readonly Instance _instanceContext; private readonly string? _gatewayAction; private readonly string? _language; + private readonly Lazy>> _rootContext; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. /// public LayoutEvaluatorState( DataModel dataModel, - LayoutModel componentModel, + LayoutModel? componentModel, FrontEndSettings frontEndSettings, Instance instance, string? gatewayAction = null, @@ -39,10 +40,7 @@ public LayoutEvaluatorState( _instanceContext = instance; _gatewayAction = gatewayAction; _language = language; - var defaultDataType = _componentModel.DefaultDataType.Id; - _defaultDataElementId = - _instanceContext.Data.Find(d => d.DataType == defaultDataType) - ?? throw new ArgumentException($"Could not find data element with data type {defaultDataType}"); + _rootContext = new(GenerateComponentContexts); } /// @@ -50,32 +48,26 @@ public LayoutEvaluatorState( /// public async Task> GetComponentContexts() { - var contexts = await Task.WhenAll( - _componentModel.Pages.Values.Select( - (async (page) => await GeneratePageContext(page, _dataModel, _defaultDataElementId)) - ) - ); - - await EvaluateHiddenExpressions(contexts); - return contexts; + return (await _rootContext.Value); } - private static async Task GeneratePageContext( - PageComponent page, - DataModel dataModel, - DataElementId dataElementId - ) + private async Task> GenerateComponentContexts() { - var children = new List(); - foreach (var child in page.Children) + var defaultDataElementId = GetDefaultElementId(); + if (_componentModel is null) + { + throw new InvalidOperationException("Component model not loaded"); + } + var pageContexts = new List(); + foreach (var page in _componentModel.Pages.Values) { - children.Add(await GenerateComponentContextsRecurs(child, dataModel, dataElementId, [])); + pageContexts.Add(await GenerateComponentContextsRecurs(page, _dataModel, defaultDataElementId, [])); } - return new ComponentContext(page, null, null, dataElementId, children); + return pageContexts; } - private static async Task GenerateComponentContextsRecurs( + private async Task GenerateComponentContextsRecurs( BaseComponent component, DataModel dataModel, DataElementId defaultDataElementId, @@ -85,9 +77,10 @@ private static async Task GenerateComponentContextsRecurs( var children = new List(); int? rowLength = null; - if ( - true /*TODO: type is subform*/ - ) { } + if (false) + { + /*TODO: type is subform*/ + } if (component is RepeatingGroupComponent repeatingGroupComponent) { if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) @@ -117,13 +110,15 @@ await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, su } } - return new ComponentContext( + var context = new ComponentContext( component, indexes?.Length > 0 ? indexes : null, rowLength, defaultDataElementId, children ); + + return context; } /// @@ -144,40 +139,69 @@ await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, su /// public BaseComponent GetComponent(string pageName, string componentId) { - return _componentModel.GetComponent(pageName, componentId); + return _componentModel?.GetComponent(pageName, componentId) + ?? throw new InvalidOperationException("Component model not loaded"); } /// /// Get a specific component context based on /// - public async Task GetComponentContext( + public async Task GetComponentContext( string pageName, string componentId, DataElementId defaultDataElementId, int[]? rowIndexes = null ) { - // First look only on the relevant page - _componentModel.Pages.TryGetValue(pageName, out var page); - if (page is null) + if (_componentModel is null) { - throw new ArgumentException($"Unknown page name {pageName}"); + throw new InvalidOperationException("Component model not loaded"); } - page.ComponentLookup.TryGetValue(componentId, out var component); - if (component is null) + + var contexts = (await GetComponentContexts()).SelectMany(c => c.Descendants); + + // Filter out all contexts that have the wrong Id + contexts = contexts.Where(c => c.Component?.Id == componentId); + // Filter out contexts that does not have a prefix matching + var filteredContexts = contexts.Where(c => CompareRowIndexes(c.RowIndices, rowIndexes)).ToArray(); + if (filteredContexts.Length == 0) { - // Look for component on other pages - component = _componentModel - .Pages.Values.Select(p => p.ComponentLookup.GetValueOrDefault(componentId)) - .Single(c => c is not null); + return null; // No context found } - if (component is null) + if (filteredContexts.Length == 1) { - throw new ArgumentException($"Unknown component id {componentId}"); + return filteredContexts[0]; + } + if (filteredContexts.Count(c => c.Component?.PageId == pageName) == 1) + { + // look first at the current page in case of duplicate ids (for backwards compatibility). + return filteredContexts.First(c => c.Component?.PageId == pageName); } - return await GenerateComponentContextsRecurs(component, _dataModel, defaultDataElementId, rowIndexes); + throw new InvalidOperationException( + $"Multiple contexts found for {componentId} with [{(rowIndexes is null ? "" : string.Join(", ", rowIndexes))}]" + ); + } + + private bool CompareRowIndexes(int[]? targetRowIndexes, int[]? sourceRowIndexes) + { + if (targetRowIndexes is null) + { + return true; + } + if (sourceRowIndexes is null) + { + return false; + } + for (int i = 0; i < targetRowIndexes.Length; i++) + { + if (targetRowIndexes[i] != sourceRowIndexes[i]) + { + return false; + } + } + return true; } /// @@ -246,7 +270,7 @@ public string GetInstanceContext(string key) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public async Task AddInidicies(ModelBinding binding, ComponentContext context) + public async Task AddInidicies(ModelBinding binding, ComponentContext context) { return await _dataModel.AddIndexes(binding, context.DataElementId, context.RowIndices); } @@ -254,7 +278,7 @@ public async Task AddInidicies(ModelBinding binding, ComponentCont /// /// Return a full dataModelBiding from a context aware binding by adding indexes /// - public async Task AddInidicies(ModelBinding binding, DataElementId dataElementId, int[]? indexes) + public async Task AddInidicies(ModelBinding binding, DataElementId dataElementId, int[]? indexes) { return await _dataModel.AddIndexes(binding, dataElementId, indexes); } @@ -313,66 +337,18 @@ public async Task AddInidicies(ModelBinding binding, DataElementId // } // } - private async Task EvaluateHiddenExpressions(IEnumerable contexts) - { - foreach (var context in contexts) - { - await EvaluateHiddenExpressionRecurs(context); - } - } - - private async Task EvaluateHiddenExpressionRecurs(ComponentContext context, bool parentIsHidden = false) - { - var hidden = - parentIsHidden || await ExpressionEvaluator.EvaluateBooleanExpression(this, context, "hidden", false); - context.IsHidden = hidden; - if ( - context.Component is RepeatingGroupComponent repGroup - && context.RowLength is not null - && repGroup.HiddenRow.IsFunctionExpression - ) - { - var hiddenRows = new List(); - foreach (var index in Enumerable.Range(0, context.RowLength.Value)) - { - var rowIndices = context.RowIndices?.Append(index).ToArray() ?? [index]; - var childContexts = context.ChildContexts.Where(c => c.RowIndices?[^1] == index); - var rowContext = new ComponentContext( - context.Component, - rowIndices, - rowLength: null, - dataElementId: context.DataElementId, - childContexts: childContexts - ); - var rowHidden = await ExpressionEvaluator.EvaluateBooleanExpression( - this, - rowContext, - "hiddenRow", - false - ); - if (rowHidden) - { - hiddenRows.Add(index); - } - } - context.HiddenRows = hiddenRows.ToArray(); - } - - foreach (var childContext in context.ChildContexts) - { - var rowIsHidden = false; - if (context.HiddenRows is not null) - { - var currentRow = childContext.RowIndices?.Last(); - rowIsHidden = currentRow is not null && context.HiddenRows.Contains(currentRow.Value); - } - await EvaluateHiddenExpressionRecurs(childContext, hidden || rowIsHidden); - } - } - - public DataElementId GetDefaultElementId() + /// + /// Get the default data element id for the current layout + /// + /// + public DataElementId GetDefaultElementId(string? dataType = null) { - return _defaultDataElementId; + var defaultDataType = + dataType + ?? _componentModel?.DefaultDataType.Id + ?? throw new InvalidOperationException("No component model, or no default data type"); + return _instanceContext.Data.Find(d => d.DataType == defaultDataType) + ?? throw new InvalidOperationException($"Could not find data element with data type {defaultDataType}"); } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 52c122a39..0c6de7ef2 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -3,8 +3,8 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -94,20 +94,21 @@ public Task Init( /// public async Task Init( - Instance instance, IInstanceDataAccessor dataAccessor, - string taskId, + string? taskId, string? gatewayAction = null, string? language = null ) { - var layouts = _appResources.GetLayoutModelForTask(taskId); + LayoutModel? layouts = null; + if (taskId is not null) + layouts = _appResources.GetLayoutModelForTask(taskId); return new LayoutEvaluatorState( new DataModel(dataAccessor), layouts, _frontEndSettings, - instance, + dataAccessor.Instance, gatewayAction, language ); diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index e05086421..f4ad64728 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -1,9 +1,12 @@ using System.Text; using System.Text.Json; using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Process.Elements; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; +using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Process; using Altinn.Platform.Storage.Interface.Models; @@ -20,16 +23,23 @@ public class ExpressionsExclusiveGateway : IProcessExclusiveGateway AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private readonly ILayoutEvaluatorStateInitializer _layoutStateInit; + private readonly IAppResources _resources; + /// /// Constructor for /// - public ExpressionsExclusiveGateway(ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) + public ExpressionsExclusiveGateway( + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, + IAppResources resources + ) { _layoutStateInit = layoutEvaluatorStateInitializer; + _resources = resources; } /// @@ -43,11 +53,9 @@ public async Task> FilterAsync( ProcessGatewayInformation processGatewayInformation ) { - var taskId = instance.Process.CurrentTask.ElementId; var state = await _layoutStateInit.Init( - instance, dataAccessor, - taskId, + taskId: null, // don't load layout for task processGatewayInformation.Action, language: null ); @@ -55,7 +63,7 @@ ProcessGatewayInformation processGatewayInformation var flows = new List(); foreach (var outgoingFlow in outgoingFlows) { - if (await EvaluateSequenceFlow(state, outgoingFlow, processGatewayInformation)) + if (await EvaluateSequenceFlow(instance, state, outgoingFlow, processGatewayInformation)) { flows.Add(outgoingFlow); } @@ -64,33 +72,36 @@ ProcessGatewayInformation processGatewayInformation return flows; } - private static async Task EvaluateSequenceFlow(LayoutEvaluatorState state, SequenceFlow sequenceFlow, ProcessGatewayInformation processGatewayInformation) + private async Task EvaluateSequenceFlow( + Instance instance, + LayoutEvaluatorState state, + SequenceFlow sequenceFlow, + ProcessGatewayInformation processGatewayInformation + ) { - if (sequenceFlow.ConditionExpression != null) + if (sequenceFlow.ConditionExpression is not null) { - var expression = GetExpressionFromCondition(sequenceFlow.ConditionExpression); - - // If there is no component context in the state, evaluate the expression once without a component context - var stateComponentContexts = (await state.GetComponentContexts()).ToList(); - if (stateComponentContexts.Count == 0) + var dataTypeId = processGatewayInformation.DataTypeId; + if (dataTypeId is null) { - stateComponentContexts.Add( - new ComponentContext( - component: null, - rowIndices: null, - rowLength: null, - dataElementId: processGatewayInformation.DataTypeId, - childContexts: null - ) - ); + // TODO: getting the data type from layout is kind of sketchy, because it depends on the previous task + // and in a future version we should probably require + var layoutSet = _resources.GetLayoutSetForTask(instance.Process.CurrentTask.ElementId); + dataTypeId = layoutSet?.DataType; } - foreach (ComponentContext? componentContext in stateComponentContexts) + var expression = GetExpressionFromCondition(sequenceFlow.ConditionExpression); + DataElementId dataElement = instance.Data.Find(d => d.DataType == dataTypeId) ?? new DataElementId(); + + var componentContext = new ComponentContext( + component: null, + rowIndices: null, + rowLength: null, + dataElement + ); + var result = await ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); + if (result is true) { - var result = await ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); - if (result is true) - { - return true; - } + return true; } } else diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index f5d5f34b1..67eb43d01 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -140,7 +140,6 @@ private async Task RemoveFieldsOnTaskComplete( // implementing frontend removal of hidden data, so //this is not updated to remove from multiple data models at once. LayoutEvaluatorState evaluationState = await _layoutEvaluatorStateInitializer.Init( - instance, dataAccessor, taskId, gatewayAction: null, diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 68151bd8d..4f63fdb79 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -1,3 +1,4 @@ +using System.Collections; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; @@ -9,6 +10,8 @@ namespace Altinn.App.Core.Models.Expressions; /// public sealed class ComponentContext { + private readonly int? _rowLength; + /// /// Constructor for ComponentContext /// @@ -23,7 +26,7 @@ public ComponentContext( DataElementId = dataElementId; Component = component; RowIndices = rowIndices; - RowLength = rowLength; + _rowLength = rowLength; ChildContexts = childContexts ?? []; foreach (var child in ChildContexts) { @@ -41,20 +44,69 @@ public ComponentContext( /// public int[]? RowIndices { get; } - /// - /// The number of rows in case the component is a repeating group - /// - public int? RowLength { get; } + private bool? _isHidden; /// - /// Whether the component is hidden + /// Memoized way to check if the component is hidden /// - public bool? IsHidden { get; set; } + public async Task IsHidden(LayoutEvaluatorState state) + { + if (_isHidden.HasValue) + { + return _isHidden.Value; + } + if (Parent is not null && await Parent.IsHidden(state)) + { + _isHidden = true; + return _isHidden.Value; + } + + _isHidden = await ExpressionEvaluator.EvaluateBooleanExpression(state, this, "hidden", false); + return _isHidden.Value; + } + + private BitArray? _hiddenRows; /// /// Hidden rows for repeating group /// - public int[]? HiddenRows { get; set; } + public async Task GetHiddenRows(LayoutEvaluatorState state) + { + if (Component is not RepeatingGroupComponent repeatingGroupComponent) + { + throw new InvalidOperationException("HiddenRows can only be called on a repeating group"); + } + if (_rowLength is null) + { + throw new InvalidOperationException("RowLength must be set to call HiddenRows on repeating group"); + } + if (_hiddenRows is not null) + { + return _hiddenRows; + } + + var hiddenRows = new BitArray(_rowLength.Value); + foreach (var index in Enumerable.Range(0, hiddenRows.Length)) + { + var rowIndices = RowIndices?.Append(index).ToArray() ?? [index]; + var childContexts = ChildContexts.Where(c => c.RowIndices?[RowIndices?.Length ?? 0] == index); + // Row contexts are not in the tree, so we need to create them here + var rowContext = new ComponentContext( + Component, + rowIndices, + rowLength: hiddenRows.Length, + dataElementId: DataElementId, + childContexts: childContexts + ); + var rowHidden = await ExpressionEvaluator.EvaluateBooleanExpression(state, rowContext, "hiddenRow", false); + + hiddenRows[index] = rowHidden; + } + + // Set the hidden rows so that it does not need to be recomputed + _hiddenRows = hiddenRows; + return _hiddenRows; + } /// /// Contexts that logically belongs under this context (eg cell => row => group=> page) diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index a072c57ae..c28417de4 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -300,7 +300,9 @@ public async Task NullName_ReturnsOkAndValidationError() var requiredList = parsedResponse.ValidationIssues.Should().ContainKey("Required").WhoseValue; var requiredName = requiredList.Should().ContainSingle().Which; requiredName.Field.Should().Be("melding.name"); - requiredName.Description.Should().Be("melding.name is required in component with id name"); + requiredName + .Description.Should() + .Be("melding.name is required in component with id name for binding simpleBinding"); // Run full validation to see that result is the same using var client = GetRootedClient(Org, App, UserId, null); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index ef81fb159..6f5675b39 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -30,8 +30,6 @@ public class ExpressionValidatorTests private readonly Mock> _logger = new(); private readonly Mock _appResources = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); - private readonly Mock _dataClient = new(MockBehavior.Strict); - private readonly Mock _appModel = new(MockBehavior.Strict); private readonly IOptions _frontendSettings = Microsoft.Extensions.Options.Options.Create( new FrontEndSettings() ); @@ -41,12 +39,6 @@ public class ExpressionValidatorTests public ExpressionValidatorTests(ITestOutputHelper output) { _output = output; - _appMetadata - .Setup(ar => ar.GetApplicationMetadata()) - .ReturnsAsync( - new ApplicationMetadata("org/app") { DataTypes = new List { new() { Id = "default" } } } - ); - _appResources.Setup(ar => ar.GetLayoutSetForTask("Task_1")).Returns(new LayoutSet()); _validator = new ExpressionValidator( _logger.Object, _appResources.Object, @@ -58,6 +50,7 @@ public ExpressionValidatorTests(ITestOutputHelper output) public async Task LoadData(string fileName, string folder) { var data = await File.ReadAllTextAsync(Path.Join(folder, fileName)); + _output.WriteLine(data); return JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; } @@ -92,13 +85,7 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var evaluatorState = new LayoutEvaluatorState(dataModel, layoutModel, _frontendSettings.Value, instance); _layoutInitializer .Setup(init => - init.Init( - It.Is(i => i == instance), - It.IsAny(), - "Task_1", - It.IsAny(), - It.IsAny() - ) + init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) ) .ReturnsAsync(evaluatorState); _appResources @@ -106,12 +93,7 @@ private async Task RunExpressionValidationTest(string fileName, string folder) .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); _appResources.Setup(ar => ar.GetLayoutSetForTask(null!)).Returns(new LayoutSet() { DataType = "default", }); - var dataAccessor = new CachedInstanceDataAccessor( - instance, - _dataClient.Object, - _appMetadata.Object, - _appModel.Object - ); + var dataAccessor = new TestInstanceDataAccessor(instance) { { dataElement, dataModel } }; var validationIssues = await _validator.ValidateFormData(instance, dataElement, dataAccessor, "Task_1", null); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 79aa6057e..7dc7a67f2 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -101,8 +101,8 @@ public async Task FilterAsync_Expression_filters_based_on_action() var data = new DummyModel(); var outgoingFlows = new List { - new SequenceFlow { Id = "1", ConditionExpression = "[\"equals\", [\"gatewayAction\"], \"confirm\"]", }, - new SequenceFlow { Id = "2", ConditionExpression = "[\"equals\", [\"gatewayAction\"], \"reject\"]", }, + new SequenceFlow { Id = "1", ConditionExpression = """["equals", ["gatewayAction"], "confirm"]""", }, + new SequenceFlow { Id = "2", ConditionExpression = """["equals", ["gatewayAction"], "reject"]""", }, }; var instance = new Instance() { @@ -147,18 +147,13 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou } }; object formData = new DummyModel() { Amount = 1000, Submitter = "test" }; - LayoutSets layoutSets = new LayoutSets() - { - Sets = new() + LayoutSet layoutSet = + new() { - new() - { - Id = "test", - Tasks = new() { "Task_1" }, - DataType = "test" - } - } - }; + Id = "test", + Tasks = new() { "Task_1" }, + DataType = DefaultDataTypeName + }; var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"notEquals\", [\"dataModel\", \"Amount\"], 1000]", }, @@ -180,8 +175,8 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_layou var (gateway, dataAccessor) = SetupExpressionsGateway( instance, dataTypes: dataTypes, - formData: formData, - layoutSets: LayoutSetsToString(layoutSets) + layoutSet: layoutSet, + formData: formData ); // Act @@ -201,7 +196,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew new() { Id = "aa", - AppLogic = new() { ClassRef = "Altinn.App.Core.Tests.Internal.Process.TestData.NotFound", } + AppLogic = new() { ClassRef = _classRef, } }, new() { @@ -211,18 +206,13 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew }; object formData = new DummyModel() { Amount = 1000, Submitter = "test" }; - LayoutSets layoutSets = new LayoutSets() - { - Sets = new() + LayoutSet layoutSet = + new() { - new() - { - Id = "test", - Tasks = new() { "Task_1" }, - DataType = DefaultDataTypeName - } - } - }; + Id = "test", + Tasks = new() { "Task_1" }, + DataType = DefaultDataTypeName + }; var outgoingFlows = new List { new SequenceFlow { Id = "1", ConditionExpression = "[\"notEquals\", [\"dataModel\", \"Amount\"], 1000]", }, @@ -241,12 +231,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew }; var processGatewayInformation = new ProcessGatewayInformation { Action = "confirm", DataTypeId = "aa" }; - var (gateway, dataAccessor) = SetupExpressionsGateway( - instance, - dataTypes, - LayoutSetsToString(layoutSets), - formData - ); + var (gateway, dataAccessor) = SetupExpressionsGateway(instance, dataTypes, layoutSet, formData); // Act var result = await gateway.FilterAsync(outgoingFlows, instance, dataAccessor, processGatewayInformation); @@ -259,37 +244,15 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew private (ExpressionsExclusiveGateway gateway, IInstanceDataAccessor dataAccessor) SetupExpressionsGateway( Instance instance, List dataTypes, - string? layoutSets = null, + LayoutSet? layoutSet = null, object? formData = null ) { - _resources.Setup(r => r.GetLayoutSets()).Returns(layoutSets ?? string.Empty); + _resources.Setup(r => r.GetLayoutSetForTask("Task_1")).Returns(layoutSet); _appMetadata .Setup(m => m.GetApplicationMetadata()) - .ReturnsAsync(new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }); - _resources - .Setup(r => r.GetLayoutModelForTask(It.IsAny())) - .Returns( - new LayoutModel() - { - DefaultDataType = dataTypes.Single(d => d.Id == DefaultDataTypeName), - Pages = new Dictionary() - { - { - "Page1", - new( - "Page1", - new List(), - new Dictionary(), - Expression.False, - Expression.False, - Expression.False, - null - ) - } - } - } - ); + .ReturnsAsync(new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }) + .Verifiable(Times.Once); if (formData != null) { _dataClient @@ -318,7 +281,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew ); var layoutStateInit = new LayoutEvaluatorStateInitializer(_resources.Object, frontendSettings); - return (new ExpressionsExclusiveGateway(layoutStateInit), dataAccessor); + return (new ExpressionsExclusiveGateway(layoutStateInit, _resources.Object), dataAccessor); } private static string LayoutSetsToString(LayoutSets layoutSets) => diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index d69f4460b..9bd0839d3 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -102,11 +102,14 @@ public class ComponentContextForTestSpec public ComponentContext ToContext(LayoutModel model, LayoutEvaluatorState state) { + var component = model.GetComponent(CurrentPageName, ComponentId); return new ComponentContext( - model.GetComponent(CurrentPageName, ComponentId), + component, RowIndices, - null, - state.GetDefaultElementId() + rowLength: component is RepeatingGroupComponent ? 0 : null, + // TODO: get from data model, but currently not important for tests + state.GetDefaultElementId(), + ChildContexts.Select(c => c.ToContext(model, state)) ); } @@ -120,8 +123,7 @@ public static ComponentContextForTestSpec FromContext(ComponentContext context) { ComponentId = context.Component.Id, CurrentPageName = context.Component.PageId, - ChildContexts = - context.ChildContexts?.Select(c => FromContext(c)) ?? Enumerable.Empty(), + ChildContexts = context.ChildContexts?.Select(FromContext) ?? [], RowIndices = context.RowIndices }; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index dafe201ff..6a8854367 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -153,6 +153,7 @@ private static async Task LoadTestCase(string file, stri testCase.FullPath = file; testCase.Folder = folder; testCase.RawJson = data; + testCase.Instance ??= new Instance(); return testCase; } @@ -200,7 +201,7 @@ await ExpressionEvaluator.EvaluateExpression( test.Context?.ToContext(componentModel, state)! ); }; - (await act.Should().ThrowAsync()).WithMessage(test.ExpectsFailure); + (await act.Should().ThrowAsync()).WithMessage($"*{test.ExpectsFailure}*"); } return; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json index 2b1221af7..fd3827d5b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json @@ -4,7 +4,7 @@ "component", "current-component" ], - "expects": null, + "expectsFailure": "non-existant", "dataModels": [ { "dataElement": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json index 407cbc59f..e9f2913b9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json @@ -5,7 +5,7 @@ "a.value", "non-existant" ], - "expects": null, + "expectsFailure": "non-existant", "dataModels": [ { "dataElement": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 5e1f5e6af..498eed154 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -71,11 +71,6 @@ public static async Task GetLayoutModelTools(object model, var modelType = model.GetType(); appModel.Setup(am => am.GetModelType(ClassRef)).Returns(modelType); - var data = new Mock(MockBehavior.Strict); - data.Setup(d => d.GetFormData(_instanceGuid, modelType, Org, App, InstanceOwnerPartyId, _dataGuid)) - .ReturnsAsync(model); - services.AddSingleton(data.Object); - var resources = new Mock(); var pages = new Dictionary(); var layoutsPath = Path.Join("LayoutExpressions", "FullTests", folder); @@ -107,8 +102,8 @@ public static async Task GetLayoutModelTools(object model, using var scope = serviceProvider.CreateScope(); var initializer = scope.ServiceProvider.GetRequiredService(); - var dataAccessor = new TestInstanceDataAccessor(_instance) { { _dataElement, data } }; + var dataAccessor = new TestInstanceDataAccessor(_instance) { { _dataElement, model } }; - return await initializer.Init(_instance, dataAccessor, TaskId); + return await initializer.Init(dataAccessor, TaskId); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index 76063117c..20551ee4f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -47,7 +47,11 @@ public async Task RemoveData_WhenPageExpressionIsTrue() "Test1" ); var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); - hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data.binding2", DataType = "default" }]); + hidden + .Should() + .BeEquivalentTo( + [new DataReference() { Field = "some.data.binding2", DataElementId = state.GetDefaultElementId() }] + ); } [Fact] @@ -80,7 +84,7 @@ public async Task RunLayoutValidationsForRequired_InvalidComponentHidden_Returns }, "Test1" ); - var validationIssues = LayoutEvaluator.RunLayoutValidationsForRequired(state); + var validationIssues = await LayoutEvaluator.RunLayoutValidationsForRequired(state); validationIssues .Should() .BeEquivalentTo(new object[] { new { Code = "required", Field = "some.data.binding3" } }); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index c0900d9f5..fb2cbc42d 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -49,9 +49,13 @@ public async Task RemoveWholeGroup() .Should() .BeEquivalentTo( [ - new ModelBinding { Field = "some.data[0].binding2", DataType = "default" }, - new ModelBinding { Field = "some.data[1].binding", DataType = "default" }, - new ModelBinding { Field = "some.data[1].binding2", DataType = "default" } + new DataReference() + { + Field = "some.data[0].binding2", + DataElementId = state.GetDefaultElementId() + }, + new DataReference { Field = "some.data[1].binding", DataElementId = state.GetDefaultElementId() }, + new DataReference { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() } ] ); @@ -89,7 +93,11 @@ public async Task RemoveSingleRow() ); var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); - hidden.Should().BeEquivalentTo([new ModelBinding() { Field = "some.data[1].binding2", DataType = "default" }]); + hidden + .Should() + .BeEquivalentTo( + [new DataReference() { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() }] + ); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index df5bcf2cd..3efc31eaa 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -51,7 +51,11 @@ public async Task RemoveRowDataFromGroup() var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists - hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); + hidden + .Should() + .BeEquivalentTo( + [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultElementId() }] + ); // Verify before removing data data.Some.Data.Should().HaveCount(3); @@ -62,7 +66,7 @@ public async Task RemoveRowDataFromGroup() data.Some.Data[2].Binding.Should().Be("hideRow"); data.Some.Data[2].Binding2.Should().Be(3); data.Some.Data[2].Binding3.Should().Be("text"); - await LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.DeleteRow); + await LayoutEvaluator.RemoveHiddenData(state, RowRemovalOption.SetToNull); // Verify row not deleted but fields null data.Some.Data.Should().HaveCount(3); @@ -108,7 +112,11 @@ public async Task RemoveRowFromGroup() var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(state); // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists - hidden.Should().BeEquivalentTo([new ModelBinding { Field = "some.data[2]", DataType = "default" }]); + hidden + .Should() + .BeEquivalentTo( + [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultElementId() }] + ); // Verify before removing data data.Some.Data.Should().HaveCount(3); From df3fa25afa5e60db88ca8692a594fe4d51f50237 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 10:00:32 +0200 Subject: [PATCH 19/63] Fix last remaining known bugs --- .../Validation/Default/ExpressionValidator.cs | 13 ++++++++++--- .../Helpers/DataModel/DataModel.cs | 18 +++++++++++++----- .../Helpers/DataModel/DataModelWrapper.cs | 8 +------- .../Internal/Expressions/LayoutEvaluator.cs | 2 +- .../Default/ExpressionValidatorTests.cs | 2 -- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 9c3744095..6cb22abee 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -2,6 +2,7 @@ using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Validation; @@ -106,18 +107,24 @@ internal async Task> ValidateFormData( gatewayAction: null, language ); - var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval(evaluatorState, true); + var hiddenFields = await LayoutEvaluator.GetHiddenFieldsForRemoval(evaluatorState); var validationIssues = new List(); var expressionValidations = ParseExpressionValidationConfig(validationConfig.RootElement, _logger); + DataElementId dataElementId = dataElement; foreach (var validationObject in expressionValidations) { - var baseField = new DataReference() { Field = validationObject.Key, DataElementId = dataElement }; + var baseField = new DataReference() { Field = validationObject.Key, DataElementId = dataElementId }; var resolvedFields = await evaluatorState.GetResolvedKeys(baseField); var validations = validationObject.Value; foreach (var resolvedField in resolvedFields) { - if (hiddenFields.Contains(resolvedField)) + if ( + hiddenFields.Exists(d => + d.DataElementId == dataElementId + && resolvedField.Field.StartsWith(d.Field, StringComparison.InvariantCulture) + ) + ) { continue; } diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 5a3962454..114d3dfaa 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -30,15 +30,23 @@ public DataModel(IInstanceDataAccessor dataAccessor) } private async Task ServiceModel(ModelBinding key, DataElementId defaultDataElementId) + { + return (await ServiceModelAndDataElementId(key, defaultDataElementId)).model; + } + + private async Task<(DataElementId dataElementId, object model)> ServiceModelAndDataElementId( + ModelBinding key, + DataElementId defaultDataElementId + ) { if (key.DataType == null) { - return await _dataAccessor.GetData(defaultDataElementId); + return (defaultDataElementId, await _dataAccessor.GetData(defaultDataElementId)); } if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) { - return await _dataAccessor.GetData(dataElementId); + return (dataElementId, await _dataAccessor.GetData(dataElementId)); } throw new InvalidOperationException("Data model with type " + key.DataType + " not found"); @@ -114,13 +122,13 @@ public async Task GetResolvedKeys(DataReference reference) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public async Task AddIndexes(ModelBinding key, DataElementId dataElementId, int[]? rowIndexes) + public async Task AddIndexes(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) { if (rowIndexes?.Length < 0) { - return new DataReference() { Field = key.Field, DataElementId = dataElementId }; + return new DataReference() { Field = key.Field, DataElementId = defaultDataElementId }; } - var serviceModel = await ServiceModel(key, dataElementId); + var (dataElementId, serviceModel) = await ServiceModelAndDataElementId(key, defaultDataElementId); if (serviceModel is null) { throw new DataModelException("Could not find service model for dataType " + key.DataType); diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index f05674c1b..07dc6bd87 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -121,7 +121,7 @@ public string[] GetResolvedKeys(string field) return GetResolvedKeysRecursive(fieldParts, _dataModel); } - internal static string JoinFieldKeyParts(string? currentKey, string? key) + private static string JoinFieldKeyParts(string? currentKey, string? key) { if (String.IsNullOrEmpty(currentKey)) { @@ -135,12 +135,6 @@ internal static string JoinFieldKeyParts(string? currentKey, string? key) return currentKey + "." + key; } - private static readonly Regex _rowIndexRegex = new Regex( - @"^([^[\]]+(\[(\d+)])?)+$", - RegexOptions.None, - TimeSpan.FromSeconds(1) - ); - private static string[] GetResolvedKeysRecursive( string[] keyParts, object currentModel, diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 0c44525bb..376114d3c 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -57,7 +57,7 @@ ComponentContext context foreach (var childContext in context.ChildContexts) { // Ignore children of hidden rows if includeHiddenRowChildren is false - if (!includeHiddenRowChildren && context.Component is RepeatingGroupComponent) + if (!includeHiddenRowChildren && childContext.Component is RepeatingGroupComponent) { var hiddenRows = await childContext.GetHiddenRows(state); var currentRow = childContext.RowIndices?[^1]; diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 6f5675b39..7149b1698 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -4,8 +4,6 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Validation.Default; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; -using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; From eaf0af863a653aac2acceb7f13fd6c13c976f96e Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 11:58:46 +0200 Subject: [PATCH 20/63] fix async warning --- .../LayoutEvaluatorStateInitializer.cs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 0c6de7ef2..c1afc906b 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -93,24 +93,31 @@ public Task Init( } /// - public async Task Init( + public Task Init( IInstanceDataAccessor dataAccessor, string? taskId, string? gatewayAction = null, string? language = null ) { - LayoutModel? layouts = null; - if (taskId is not null) - layouts = _appResources.GetLayoutModelForTask(taskId); + try + { + LayoutModel? layouts = taskId is not null ? _appResources.GetLayoutModelForTask(taskId) : null; - return new LayoutEvaluatorState( - new DataModel(dataAccessor), - layouts, - _frontEndSettings, - dataAccessor.Instance, - gatewayAction, - language - ); + return Task.FromResult( + new LayoutEvaluatorState( + new DataModel(dataAccessor), + layouts, + _frontEndSettings, + dataAccessor.Instance, + gatewayAction, + language + ) + ); + } + catch (Exception e) + { + return Task.FromException(e); + } } } From 278e1700d0f04a2b1f33705368dcb4b888df9d10 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 12:27:48 +0200 Subject: [PATCH 21/63] Don't filter fields to remove by doing a double lookup --- .../InterfaceFactory/InterfaceFactory.cs | 49 ------------------- .../Internal/Expressions/LayoutEvaluator.cs | 10 +--- .../FullTests/Test1/RunTest1.cs | 5 +- .../FullTests/Test2/RunTest2.cs | 5 +- 4 files changed, 9 insertions(+), 60 deletions(-) delete mode 100644 src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs diff --git a/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs b/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs deleted file mode 100644 index 17b2b538c..000000000 --- a/src/Altinn.App.Core/Features/InterfaceFactory/InterfaceFactory.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Runtime.InteropServices.Marshalling; -using Altinn.App.Core.Internal.Process.ProcessTasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; - -namespace Altinn.App.Core.Features.InterfaceFactory; - -internal class InterfaceFactory -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly Telemetry _telemetry; - - private IServiceProvider _serviceProvider => - _httpContextAccessor.HttpContext?.RequestServices - ?? throw new InvalidOperationException("RequestServices is null"); - - public InterfaceFactory(IHttpContextAccessor httpContextAccessor, Telemetry telemetry) - { - _httpContextAccessor = httpContextAccessor; - _telemetry = telemetry; - } - - private T[] GetServices() - where T : notnull - { - var stopwatch = new System.Diagnostics.Stopwatch(); - stopwatch.Start(); - - // call .ToArray to ensure that the services are fully - // resolved before stopping the stopwatch. - var services = _serviceProvider.GetServices().ToArray(); - - stopwatch.Stop(); - var elapsedMilliseconds = stopwatch.ElapsedMilliseconds; - if (elapsedMilliseconds > 1) - { - var message = $"Resolved {services.Length} services of type {typeof(T).Name} in {elapsedMilliseconds} ms"; - var serviceNames = services.Select(s => s.GetType().Name).ToArray(); - //TODO: add telemetry span for this service initialization. - } - return services; - } - - public IDataProcessor[] GetDataProcessors() => GetServices(); - - public IInstantiationProcessor[] GetInstantiationProcessors() => GetServices(); - - public IProcessTaskInitializer[] GetProcessTaskInitializers() => GetServices(); -} diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 376114d3c..6fe97cf84 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -34,15 +34,7 @@ await HiddenFieldsForRemovalRecurs( } var forRemoval = hiddenModelBindings.Except(nonHiddenModelBindings); - var existsForRemoval = new List(); - foreach (var keyToRemove in forRemoval) - { - if (await state.GetModelData(keyToRemove.Field, keyToRemove.DataElementId, default) is not null) - { - existsForRemoval.Add(keyToRemove); - } - } - return existsForRemoval; + return forRemoval.ToList(); } private static async Task HiddenFieldsForRemovalRecurs( diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index 20551ee4f..eea344a5f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -50,7 +50,10 @@ public async Task RemoveData_WhenPageExpressionIsTrue() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data.binding2", DataElementId = state.GetDefaultElementId() }] + [ + new DataReference() { Field = "some.data.binding3", DataElementId = state.GetDefaultElementId() }, + new DataReference() { Field = "some.data.binding2", DataElementId = state.GetDefaultElementId() } + ] ); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index fb2cbc42d..c391bc46c 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -49,13 +49,16 @@ public async Task RemoveWholeGroup() .Should() .BeEquivalentTo( [ + new DataReference { Field = "some.data[0].binding", DataElementId = state.GetDefaultElementId() }, new DataReference() { Field = "some.data[0].binding2", DataElementId = state.GetDefaultElementId() }, + new DataReference { Field = "some.data[0].binding3", DataElementId = state.GetDefaultElementId() }, new DataReference { Field = "some.data[1].binding", DataElementId = state.GetDefaultElementId() }, - new DataReference { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() } + new DataReference { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() }, + new DataReference { Field = "some.data[1].binding3", DataElementId = state.GetDefaultElementId() } ] ); From 269e0a3f566075269a9b0050bc9d8e91fe3b5168 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 14:06:50 +0200 Subject: [PATCH 22/63] Fix sonar suggestions and tests --- src/Altinn.App.Core/Helpers/DataModel/DataModel.cs | 6 +----- .../Controllers/DataController_PatchTests.cs | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 114d3dfaa..c5e1723a6 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -124,10 +124,6 @@ public async Task GetResolvedKeys(DataReference reference) /// public async Task AddIndexes(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) { - if (rowIndexes?.Length < 0) - { - return new DataReference() { Field = key.Field, DataElementId = defaultDataElementId }; - } var (dataElementId, serviceModel) = await ServiceModelAndDataElementId(key, defaultDataElementId); if (serviceModel is null) { @@ -142,7 +138,7 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa /// /// Set the value of a field in the model to default (null) /// - public async void RemoveField( + public async Task RemoveField( ModelBinding key, DataElementId defaultDataElementId, RowRemovalOption rowRemovalOption diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index c28417de4..5d4ba1f3a 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -198,6 +198,7 @@ public async Task MultiplePatches_AppliesCorrectly() { ClassRef = "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", + AllowUserCreate = true, // We use api to initialize this as a user. }, } ); From c92d62b0397279f1c59dae5a75df17c52907ae3f Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 14:09:15 +0200 Subject: [PATCH 23/63] fix another warning --- src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs | 2 +- .../Internal/Expressions/LayoutEvaluatorState.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 6fe97cf84..0db558ad4 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -127,7 +127,7 @@ public static async Task RemoveHiddenData(LayoutEvaluatorState state, RowRemoval var fields = await GetHiddenFieldsForRemoval(state); foreach (var dataReference in fields) { - state.RemoveDataField(dataReference.Field, dataReference.DataElementId, rowRemovalOption); + await state.RemoveDataField(dataReference.Field, dataReference.DataElementId, rowRemovalOption); } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 12e90e5b7..93fd7d2f7 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -223,9 +223,9 @@ public async Task GetResolvedKeys(DataReference reference) /// /// Set the value of a field to null. /// - public void RemoveDataField(ModelBinding key, DataElementId dataElementId, RowRemovalOption rowRemovalOption) + public async Task RemoveDataField(ModelBinding key, DataElementId dataElementId, RowRemovalOption rowRemovalOption) { - _dataModel.RemoveField(key, dataElementId, rowRemovalOption); + await _dataModel.RemoveField(key, dataElementId, rowRemovalOption); } /// From 46dafb499ce7a064834dd83a40d3f7273baba971 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 4 Sep 2024 22:06:23 +0200 Subject: [PATCH 24/63] Fix another batch of sonar cloud issues --- .../Controllers/DataController.cs | 5 +- .../Helpers/DataModel/DataModel.cs | 11 +- .../Helpers/DataModel/DataModelWrapper.cs | 118 +++++++++--------- .../Internal/Data/CachedFormDataAccessor.cs | 2 +- .../Expressions/ExpressionEvaluator.cs | 1 - .../Expressions/LayoutEvaluatorState.cs | 5 +- .../LayoutEvaluatorStateInitializer.cs | 2 +- .../Process/ExpressionsExclusiveGateway.cs | 1 - .../Models/Expressions/ComponentContext.cs | 2 +- .../Models/Layout/LayoutModel.cs | 41 ------ .../Controllers/DataController_PatchTests.cs | 6 +- .../TestBackendExclusiveFunctions.cs | 2 +- .../DynamicClassBuilderChatGPTTests.cs | 4 +- 13 files changed, 74 insertions(+), 126 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 4d9121ae8..469761c67 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -324,7 +324,8 @@ public async Task Get( if (dataType is null) { - var error = $"Could not determine if {dataType} requires app logic for application {org}/{app}"; + var error = + $"Could not determine if {dataElement?.DataType} requires app logic for application {org}/{app}"; _logger.LogError(error); return BadRequest(error); } @@ -551,7 +552,7 @@ public async Task> PatchFormDataMultiple { _logger.LogError( "Could not determine if {dataType} requires app logic for application {org}/{app}", - dataType, + dataType?.Id, org, app ); diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index c5e1723a6..53d67067e 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -1,7 +1,5 @@ -using System.Collections; using System.Text.RegularExpressions; using Altinn.App.Core.Features; -using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; @@ -13,7 +11,7 @@ namespace Altinn.App.Core.Helpers.DataModel; public class DataModel { private readonly IInstanceDataAccessor _dataAccessor; - private readonly Dictionary _dataIdsByType = new(); + private readonly Dictionary _dataIdsByType = []; /// /// Constructor that wraps a POCO data model, and gives extra tool for working with the data @@ -98,11 +96,8 @@ public async Task GetResolvedKeys(DataReference reference) .ToArray(); } - private static readonly Regex _rowIndexRegex = new Regex( - @"^([^[\]]+(\[(\d+)])?)+$", - RegexOptions.None, - TimeSpan.FromMilliseconds(10) - ); + private static readonly Regex _rowIndexRegex = + new(@"^([^[\]]+(\[(\d+)])?)+$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(10)); /// /// Get the row indices from a key diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 07dc6bd87..382560c23 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -123,13 +123,13 @@ public string[] GetResolvedKeys(string field) private static string JoinFieldKeyParts(string? currentKey, string? key) { - if (String.IsNullOrEmpty(currentKey)) + if (string.IsNullOrEmpty(currentKey)) { return key ?? ""; } - if (String.IsNullOrEmpty(key)) + if (string.IsNullOrEmpty(key)) { - return currentKey ?? ""; + return currentKey; } return currentKey + "." + key; @@ -153,7 +153,7 @@ private static string[] GetResolvedKeysRecursive( } var (key, groupIndex) = ParseKeyPart(keyParts[currentIndex]); - var prop = currentModel.GetType().GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + var prop = Array.Find(currentModel.GetType().GetProperties(), p => IsPropertyWithJsonName(p, key)); var childModel = prop?.GetValue(currentModel); if (childModel is null) { @@ -208,7 +208,8 @@ private static string[] GetResolvedKeysRecursive( return null; } - private static readonly Regex _keyPartRegex = new Regex(@"^([^\s\[\]\.]+)\[(\d+)\]?$"); + private static readonly Regex _keyPartRegex = + new(@"^([^\s\[\]\.]+)\[(\d+)\]?$", RegexOptions.Compiled, TimeSpan.FromMicroseconds(10)); internal static (string key, int? index) ParseKeyPart(string keyPart) { @@ -236,7 +237,7 @@ ReadOnlySpan indicies return; } var (key, groupIndex) = ParseKeyPart(keys[0]); - var prop = currentModelType.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + var prop = Array.Find(currentModelType.GetProperties(), p => IsPropertyWithJsonName(p, key)); if (prop is null) { throw new DataModelException($"Unknown model property {key} in {string.Join(".", ret)}.{key}"); @@ -357,10 +358,7 @@ public void RemoveField(string field, RowRemovalOption rowRemovalOption) throw new NotImplementedException($"Tried to remove field {field}, ended in an enumerable"); } - var property = containingObject - .GetType() - .GetProperties() - .FirstOrDefault(p => IsPropertyWithJsonName(p, lastKey)); + var property = Array.Find(containingObject.GetType().GetProperties(), p => IsPropertyWithJsonName(p, lastKey)); if (property is null) { return; @@ -401,54 +399,54 @@ public void RemoveField(string field, RowRemovalOption rowRemovalOption) } } - /// - /// Verify that a key is valid for the model - /// - public bool VerifyKey(string field) - { - return VerifyKeyRecursive(field.Split('.'), 0, _dataModel.GetType()); - } - - private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) - { - if (index == keys.Length) - { - return true; - } - if (keys[index].Length == 0) - { - return false; // invalid key part - } - - var (key, groupIndex) = ParseKeyPart(keys[index]); - var prop = currentModel.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); - if (prop is null) - { - return false; - } - - var childType = prop.PropertyType; - - // Strings are enumerable in C# - // Other enumerable types is treated as an collection - if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) - { - var childTypeEnumerableParameter = childType - .GetInterfaces() - .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - .Select(t => t.GetGenericArguments()[0]) - .FirstOrDefault(); - - if (childTypeEnumerableParameter is not null) - { - return VerifyKeyRecursive(keys, index + 1, childTypeEnumerableParameter); - } - } - else if (groupIndex is not null) - { - return false; // Key parts with group index must be IEnumerable - } - - return VerifyKeyRecursive(keys, index + 1, childType); - } + // /// + // /// Verify that a key is valid for the model + // /// + // public bool VerifyKey(string field) + // { + // return VerifyKeyRecursive(field.Split('.'), 0, _dataModel.GetType()); + // } + + // private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) + // { + // if (index == keys.Length) + // { + // return true; + // } + // if (keys[index].Length == 0) + // { + // return false; // invalid key part + // } + + // var (key, groupIndex) = ParseKeyPart(keys[index]); + // var prop = currentModel.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); + // if (prop is null) + // { + // return false; + // } + + // var childType = prop.PropertyType; + + // // Strings are enumerable in C# + // // Other enumerable types is treated as an collection + // if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) + // { + // var childTypeEnumerableParameter = childType + // .GetInterfaces() + // .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + // .Select(t => t.GetGenericArguments()[0]) + // .FirstOrDefault(); + + // if (childTypeEnumerableParameter is not null) + // { + // return VerifyKeyRecursive(keys, index + 1, childTypeEnumerableParameter); + // } + // } + // else if (groupIndex is not null) + // { + // return false; // Key parts with group index must be IEnumerable + // } + + // return VerifyKeyRecursive(keys, index + 1, childType); + // } } diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs index bf059e453..8d83bda8a 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs @@ -53,7 +53,7 @@ IAppModel appModel { throw new InvalidOperationException($"Data type {dataType} not found in app metadata"); } - if (dataTypeObj?.MaxCount != 1) + if (dataTypeObj.MaxCount != 1) { throw new InvalidOperationException($"Data type {dataType} is not a single data type"); } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 53ee2fc54..313399db0 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -70,7 +70,6 @@ bool defaultReturn { args[i] = await EvaluateExpression(state, expr.Args[i], context, positionalArguments); } - // var args = expr.Args.Select(a => await EvaluateExpression(state, a, context, positionalArguments)).ToArray(); // ! TODO: should find better ways to deal with nulls here for the next major version var ret = expr.Function switch { diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 93fd7d2f7..4016e6e44 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,4 +1,3 @@ -using System.Collections; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models; @@ -67,7 +66,7 @@ private async Task> GenerateComponentContexts() return pageContexts; } - private async Task GenerateComponentContextsRecurs( + private static async Task GenerateComponentContextsRecurs( BaseComponent component, DataModel dataModel, DataElementId defaultDataElementId, @@ -184,7 +183,7 @@ public BaseComponent GetComponent(string pageName, string componentId) ); } - private bool CompareRowIndexes(int[]? targetRowIndexes, int[]? sourceRowIndexes) + private static bool CompareRowIndexes(int[]? targetRowIndexes, int[]? sourceRowIndexes) { if (targetRowIndexes is null) { diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index c1afc906b..6674108d6 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -33,7 +33,7 @@ public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions /// is removed /// - private class SingleDataElementAccessor : IInstanceDataAccessor + private sealed class SingleDataElementAccessor : IInstanceDataAccessor { private readonly DataElement _dataElement; private readonly object _data; diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index f4ad64728..6a29a1102 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -6,7 +6,6 @@ using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; -using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Process; using Altinn.Platform.Storage.Interface.Models; diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 4f63fdb79..fe67508f7 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -72,7 +72,7 @@ public async Task IsHidden(LayoutEvaluatorState state) /// public async Task GetHiddenRows(LayoutEvaluatorState state) { - if (Component is not RepeatingGroupComponent repeatingGroupComponent) + if (Component is not RepeatingGroupComponent) { throw new InvalidOperationException("HiddenRows can only be called on a repeating group"); } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 4e231b77e..15b3fc1ab 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -1,4 +1,3 @@ -using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; @@ -52,44 +51,4 @@ public IEnumerable GetComponents() nodes.Push(n); } } - - // /// - // /// Get all external model references used in the layout model - // /// - // public IEnumerable GetReferencedDataTypeIds() - // { - // var externalModelReferences = new HashSet(); - // foreach (var component in GetComponents()) - // { - // // Add data model references from DataModelBindings - // externalModelReferences.UnionWith( - // component.DataModelBindings.Values.Select(d => d.DataType).OfType() - // ); - // - // // Add data model references from expressions - // AddExternalModelReferences(component.Hidden, externalModelReferences); - // AddExternalModelReferences(component.ReadOnly, externalModelReferences); - // AddExternalModelReferences(component.Required, externalModelReferences); - // //TODO: add more expressions when backend uses them - // } - // - // //Ensure that the defaultData type is first in the resulting enumerable. - // externalModelReferences.Remove(DefaultDataType.Id); - // return externalModelReferences.Prepend(DefaultDataType.Id); - // } - // - // private static void AddExternalModelReferences(Expression expression, HashSet externalModelReferences) - // { - // if ( - // expression is - // { Function: ExpressionFunction.dataModel, Args: [_, { Value: string externalModelReference }] } - // ) - // { - // externalModelReferences.Add(externalModelReference); - // } - // else - // { - // expression.Args?.ForEach(arg => AddExternalModelReferences(arg, externalModelReferences)); - // } - // } } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 5d4ba1f3a..6c525bb63 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -220,11 +220,9 @@ public async Task MultiplePatches_AppliesCorrectly() .Verifiable(Times.Exactly(2)); // Initialize extra data element + using var content = new StringContent("""{"melding":{}}""", Encoding.UTF8, "application/json"); var createExtraElementResponse = await GetClient() - .PostAsync( - $"{Org}/{App}/instances/{_instanceId}/data?dataType={prefillDataType}", - new StringContent("""{"melding":{}}""", Encoding.UTF8, "application/json") - ); + .PostAsync($"{Org}/{App}/instances/{_instanceId}/data?dataType={prefillDataType}", content); var createExtraElementResponseString = await createExtraElementResponse.Content.ReadAsStringAsync(); _outputHelper.WriteLine(createExtraElementResponseString); createExtraElementResponse.Should().HaveStatusCode(HttpStatusCode.Created); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 5cf50e1d0..ea69aee59 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -25,7 +25,7 @@ public TestBackendExclusiveFunctions(ITestOutputHelper output) [ExclusiveTest("gatewayAction")] public async Task GatewayAction_Theory(string testName, string folder) => await RunTestCase(testName, folder); - private async Task LoadTestCase(string testName, string folder) + private static async Task LoadTestCase(string testName, string folder) { var file = Path.Join(folder, testName); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs index e6db8445f..b8d23769e 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilderChatGPTTests.cs @@ -102,8 +102,8 @@ public void CreateClassFromJson_ShouldCreateClassWithArrayProperty() var deserializedObject = JsonSerializer.Deserialize(jsonString, dynamicType); var numbersProperty = dynamicType.GetProperty("Numbers")!.GetValue(deserializedObject) as List; - numbersProperty.Should().NotBeNull(); - numbersProperty.Should().BeEquivalentTo(new List { 1.0, 2.0, 3.0 }); + numbersProperty!.Should().NotBeNull(); + numbersProperty!.Should().BeEquivalentTo(new List { 1.0, 2.0, 3.0 }); } [Fact] From 7ea4e547e67a4002537a30d61f04f08bced45ec9 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 00:21:48 +0200 Subject: [PATCH 25/63] Implement SubFormComponent and use for validation --- .../Helpers/DataModel/DataModel.cs | 6 + .../Helpers/DataModel/DataModelWrapper.cs | 30 +++-- .../Implementation/AppResourcesSI.cs | 46 ++++--- .../Expressions/ExpressionEvaluator.cs | 7 +- .../Expressions/LayoutEvaluatorState.cs | 109 +++------------- .../LayoutEvaluatorStateInitializer.cs | 13 +- .../Layout/Components/SubFormComponent.cs | 67 ++++++++++ .../Models/Layout/LayoutModel.cs | 123 ++++++++++++++++-- .../Models/Layout/LayoutSetComponent.cs | 61 +++++++++ .../Models/Layout/PageComponentConverter.cs | 75 +++++++---- src/Altinn.App.Core/Models/LayoutSet.cs | 9 +- src/Altinn.App.Core/Models/LayoutSets.cs | 2 +- .../Altinn.App.Api.Tests.csproj | 1 - .../CustomWebApplicationFactory.cs | 23 +--- .../ui/{ => default}/Settings.json | 0 .../ui/{ => default}/layouts/page.json | 0 .../ui/layout-sets.json | 10 ++ .../Altinn.App.Common.Tests.csproj | 1 + .../FakeLoggerXunit.cs | 55 ++++++++ .../Default/ExpressionValidatorTests.cs | 18 +-- .../CommonTests/ExpressionTestCaseRoot.cs | 14 +- .../TestBackendExclusiveFunctions.cs | 11 +- .../CommonTests/TestContextList.cs | 13 +- .../CommonTests/TestFunctions.cs | 20 +-- .../CommonTests/TestInvalid.cs | 15 ++- .../FullTests/LayoutTestUtils.cs | 16 +-- .../FullTests/Test1/RunTest1.cs | 12 +- .../FullTests/Test2/RunTest2.cs | 40 +++++- .../FullTests/Test3/RunTest3.cs | 4 +- .../TestUtilities/DynamicClassBuilder.cs | 15 ++- ...ccessor.cs => InstanceDataAccessorFake.cs} | 31 ++++- 31 files changed, 575 insertions(+), 272 deletions(-) create mode 100644 src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs create mode 100644 src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs rename test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/{ => default}/Settings.json (100%) rename test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/{ => default}/layouts/page.json (100%) create mode 100644 test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layout-sets.json create mode 100644 test/Altinn.App.Common.Tests/FakeLoggerXunit.cs rename test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/{TestInstanceDataAccessor.cs => InstanceDataAccessorFake.cs} (51%) diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 53d67067e..43f7c04c2 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -2,6 +2,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Helpers.DataModel; @@ -27,6 +28,11 @@ public DataModel(IInstanceDataAccessor dataAccessor) } } + /// + /// Get access to the instance object + /// + public Instance Instance => _dataAccessor.Instance; + private async Task ServiceModel(ModelBinding key, DataElementId defaultDataElementId) { return (await ServiceModelAndDataElementId(key, defaultDataElementId)).model; diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs index 382560c23..3c00c627a 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModelWrapper.cs @@ -109,7 +109,17 @@ ReadOnlySpan rowIndexes return GetModelDataRecursive(keys, index + 1, elementAt, rowIndexes.Length > 0 ? rowIndexes[1..] : rowIndexes); } - /// + /// + /// Get all valid indexed keys for the field, depending on the number of rows in repeating groups + /// + /// + /// GetResolvedKeys("data.bedrifter.styre.medlemmer") => + /// [ + /// "data.bedrifter[0].styre.medlemmer", + /// "data.bedrifter[1].styre.medlemmer" + /// ... + /// ] + /// public string[] GetResolvedKeys(string field) { if (_dataModel is null) @@ -211,7 +221,7 @@ private static string[] GetResolvedKeysRecursive( private static readonly Regex _keyPartRegex = new(@"^([^\s\[\]\.]+)\[(\d+)\]?$", RegexOptions.Compiled, TimeSpan.FromMicroseconds(10)); - internal static (string key, int? index) ParseKeyPart(string keyPart) + private static (string key, int? index) ParseKeyPart(string keyPart) { if (keyPart.Length == 0) { @@ -225,11 +235,11 @@ internal static (string key, int? index) ParseKeyPart(string keyPart) return (match.Groups[1].Value, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); } - private static void AddIndiciesRecursive( + private static void AddIndexesRecursive( List ret, Type currentModelType, ReadOnlySpan keys, - ReadOnlySpan indicies + ReadOnlySpan indexes ) { if (keys.Length == 0) @@ -243,7 +253,7 @@ ReadOnlySpan indicies throw new DataModelException($"Unknown model property {key} in {string.Join(".", ret)}.{key}"); } - var currentIndex = groupIndex ?? (indicies.Length > 0 ? indicies[0] : null); + var currentIndex = groupIndex ?? (indexes.Length > 0 ? indexes[0] : null); var childType = prop.PropertyType; // Strings are enumerable in C# @@ -263,12 +273,12 @@ ReadOnlySpan indicies } ret.Add($"{key}[{currentIndex}]"); - if (indicies.Length > 0) + if (indexes.Length > 0) { - indicies = indicies.Slice(1); + indexes = indexes.Slice(1); } - AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), indicies); + AddIndexesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), indexes); } else { @@ -278,7 +288,7 @@ ReadOnlySpan indicies } ret.Add(key); - AddIndiciesRecursive(ret, childType, keys.Slice(1), indicies); + AddIndexesRecursive(ret, childType, keys.Slice(1), indexes); } } @@ -298,7 +308,7 @@ public string AddIndicies(string field, ReadOnlySpan rowIndexes = default) } var ret = new List(); - AddIndiciesRecursive(ret, _dataModel.GetType(), field.Split('.'), rowIndexes); + AddIndexesRecursive(ret, _dataModel.GetType(), field.Split('.'), rowIndexes); return string.Join('.', ret); } diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index aa6bfe994..501b412a7 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -320,39 +320,47 @@ public LayoutModel GetLayoutModel(string? layoutSetId = null) public LayoutModel GetLayoutModelForTask(string taskId) { using var activity = _telemetry?.StartGetLayoutModelActivity(); - var layoutSet = GetLayoutSetForTask(taskId); + var layoutSets = GetLayoutSet() ?? throw new InvalidOperationException("no layout sets found"); + var dataTypes = _appMetadata.GetApplicationMetadata().Result.DataTypes; + + var layouts = layoutSets.Sets.Select(set => LoadLayout(set, dataTypes)).ToList(); + + var layoutSet = + GetLayoutSetForTask(taskId) + ?? throw new InvalidOperationException("No layout set found for task " + taskId); + + return new LayoutModel(layouts, layoutSet); + } + + private LayoutSetComponent LoadLayout(LayoutSet layoutSet, List dataTypes) + { var order = - GetLayoutSettingsForSet(layoutSet?.Id)?.Pages?.Order - ?? throw new InvalidDataException( - "No $Pages.Order field found" + (layoutSet?.Id is null ? "" : $" for layoutSet {layoutSet.Id}") - ); + GetLayoutSettingsForSet(layoutSet.Id)?.Pages?.Order + ?? throw new InvalidDataException($"No $Pages.Order field found for layoutSet {layoutSet.Id}"); - var pages = new Dictionary(); - string folder = Path.Join(_settings.AppBasePath, _settings.UiFolder, layoutSet?.Id, "layouts"); + var pages = new List(); + string folder = Path.Join(_settings.AppBasePath, _settings.UiFolder, layoutSet.Id, "layouts"); foreach (var page in order) { var pageBytes = File.ReadAllBytes(Path.Join(folder, page + ".json")); // Set the PageName using AsyncLocal before deserializing. PageComponentConverter.SetAsyncLocalPageName(page); - pages[page] = + pages.Add( System.Text.Json.JsonSerializer.Deserialize( pageBytes.RemoveBom(), _jsonSerializerOptions - ) ?? throw new InvalidDataException(page + ".json is \"null\""); + ) ?? throw new InvalidDataException(page + ".json is \"null\"") + ); } - return new LayoutModel() { DefaultDataType = GetDefaultDataType(taskId, layoutSet), Pages = pages, }; - } - - private DataType GetDefaultDataType(string taskId, LayoutSet? layoutSet) - { - var appMetadata = _appMetadata.GetApplicationMetadata().Result; - // First look for the layoutSet.DataType, then look for the first DataType with a classRef - return appMetadata.DataTypes.Find(d => layoutSet?.DataType == d.Id) - ?? appMetadata.DataTypes.Find(d => d.AppLogic?.ClassRef is not null && d.TaskId == taskId) + // First look at the specified data type, but + var dataType = + dataTypes.Find(d => d.Id == layoutSet.DataType) + ?? dataTypes.Find(d => d.AppLogic?.ClassRef is not null) ?? throw new InvalidOperationException( - $"No data type found for task {taskId} and layoutSet {layoutSet?.Id}" + $"LayoutSet {layoutSet.Id} asks for dataType {layoutSet.DataType}, but it does not exist in applicationmetadata.json" ); + return new LayoutSetComponent(pages, layoutSet.Id, dataType); } /// diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 313399db0..102c37874 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -156,12 +156,7 @@ LayoutEvaluatorState state throw new ArgumentException("The component expression requires a component context"); } - var targetContext = await state.GetComponentContext( - context.Component.PageId, - componentId, - context.DataElementId, - context.RowIndices - ); + var targetContext = await state.GetComponentContext(context.Component.PageId, componentId, context.RowIndices); if (targetContext is null) { diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 4016e6e44..74204d2c8 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -1,4 +1,5 @@ using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; @@ -19,105 +20,38 @@ public class LayoutEvaluatorState private readonly Instance _instanceContext; private readonly string? _gatewayAction; private readonly string? _language; - private readonly Lazy>> _rootContext; + private readonly Lazy>?> _rootContext; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. /// public LayoutEvaluatorState( - DataModel dataModel, + IInstanceDataAccessor dataAccessor, LayoutModel? componentModel, FrontEndSettings frontEndSettings, - Instance instance, string? gatewayAction = null, string? language = null ) { - _dataModel = dataModel; + _dataModel = new DataModel(dataAccessor); _componentModel = componentModel; _frontEndSettings = frontEndSettings; - _instanceContext = instance; + _instanceContext = dataAccessor.Instance; _gatewayAction = gatewayAction; _language = language; - _rootContext = new(GenerateComponentContexts); + _rootContext = new(() => _componentModel?.GenerateComponentContexts(_instanceContext, _dataModel)); } /// /// Get a hierarchy of the different contexts in the component model (remember to iterate ) /// - public async Task> GetComponentContexts() + public async Task> GetComponentContexts() { - return (await _rootContext.Value); - } - - private async Task> GenerateComponentContexts() - { - var defaultDataElementId = GetDefaultElementId(); - if (_componentModel is null) + if (_rootContext.Value is null) { throw new InvalidOperationException("Component model not loaded"); } - var pageContexts = new List(); - foreach (var page in _componentModel.Pages.Values) - { - pageContexts.Add(await GenerateComponentContextsRecurs(page, _dataModel, defaultDataElementId, [])); - } - - return pageContexts; - } - - private static async Task GenerateComponentContextsRecurs( - BaseComponent component, - DataModel dataModel, - DataElementId defaultDataElementId, - int[]? indexes - ) - { - var children = new List(); - int? rowLength = null; - - if (false) - { - /*TODO: type is subform*/ - } - if (component is RepeatingGroupComponent repeatingGroupComponent) - { - if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) - { - rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; - foreach (var index in Enumerable.Range(0, rowLength.Value)) - { - foreach (var child in repeatingGroupComponent.Children) - { - // concatenate [...indexes, index] - var subIndexes = new int[(indexes?.Length ?? 0) + 1]; - indexes.CopyTo(subIndexes.AsSpan()); - subIndexes[^1] = index; - - children.Add( - await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) - ); - } - } - } - } - else if (component is GroupComponent groupComponent) - { - foreach (var child in groupComponent.Children) - { - children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); - } - } - - var context = new ComponentContext( - component, - indexes?.Length > 0 ? indexes : null, - rowLength, - defaultDataElementId, - children - ); - - return context; + return (await _rootContext.Value); } /// @@ -148,7 +82,6 @@ public BaseComponent GetComponent(string pageName, string componentId) public async Task GetComponentContext( string pageName, string componentId, - DataElementId defaultDataElementId, int[]? rowIndexes = null ) { @@ -282,6 +215,15 @@ public async Task AddInidicies(ModelBinding binding, DataElementI return await _dataModel.AddIndexes(binding, dataElementId, indexes); } + /// + /// This is the wrong abstraction, but used in tests that work + /// + internal DataElementId GetDefaultDataElementId() + { + return _componentModel?.GetDefaultDataElementId(_instanceContext) + ?? throw new InvalidOperationException("Component model not loaded"); + } + // /// // /// Verify all components that dataModel references are correct // /// @@ -335,19 +277,4 @@ public async Task AddInidicies(ModelBinding binding, DataElementI // GetModelErrorsForExpression(arg, component, errors); // } // } - - - /// - /// Get the default data element id for the current layout - /// - /// - public DataElementId GetDefaultElementId(string? dataType = null) - { - var defaultDataType = - dataType - ?? _componentModel?.DefaultDataType.Id - ?? throw new InvalidOperationException("No component model, or no default data type"); - return _instanceContext.Data.Find(d => d.DataType == defaultDataType) - ?? throw new InvalidOperationException($"Could not find data element with data type {defaultDataType}"); - } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 6674108d6..c7639afe8 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -87,9 +87,7 @@ public Task Init( var dataElement = instance.Data.Find(d => d.DataType == layouts.DefaultDataType.Id); Debug.Assert(dataElement is not null); var dataAccessor = new SingleDataElementAccessor(instance, dataElement, data); - return Task.FromResult( - new LayoutEvaluatorState(new DataModel(dataAccessor), layouts, _frontEndSettings, instance, gatewayAction) - ); + return Task.FromResult(new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, gatewayAction)); } /// @@ -105,14 +103,7 @@ public Task Init( LayoutModel? layouts = taskId is not null ? _appResources.GetLayoutModelForTask(taskId) : null; return Task.FromResult( - new LayoutEvaluatorState( - new DataModel(dataAccessor), - layouts, - _frontEndSettings, - dataAccessor.Instance, - gatewayAction, - language - ) + new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, gatewayAction, language) ); } catch (Exception e) diff --git a/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs new file mode 100644 index 000000000..f776619f1 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs @@ -0,0 +1,67 @@ +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models.Layout.Components; + +/// +/// Component for handling subforms +/// +public record SubFormComponent : BaseComponent +{ + /// Constructor + /// + /// Note that some properties are commented out, as they are currently not used, and might allow expressions in the future + /// + public SubFormComponent( + string id, + string type, + IReadOnlyDictionary? dataModelBindings, + string layoutSetId, + // List tableColumns, + // bool showAddButton, + // bool showDeleteButton, + Expression hidden, + Expression required, + Expression readOnly, + IReadOnlyDictionary? additionalProperties + ) + : base(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties) + { + LayoutSetId = layoutSetId; + // TableColumns = tableColumns; + // ShowAddButton = showAddButton; + // ShowDeleteButton = showDeleteButton; + } + + /// + /// The layout set to load for this subform + /// + public string LayoutSetId { get; } + + // /// + // /// Specification for preview of subForms in main form + // /// + // public List TableColumns { get; } + // /// + // /// Show button to add a new row + // /// + // public bool ShowAddButton { get; } + // /// + // /// Show button to remove a row + // /// + // public bool ShowDeleteButton { get; } + + + /// + /// Specification for preview of subForms in main form + /// + /// The header value to display. May contain a text resource bindings, but no data model lookups. + /// + public record TableColumn(string HeaderContent, CellContent CellContent); + + /// + /// How to select the content of a cell in a subform preview + /// + /// The cell value to display from a data model lookup (dot notation). + /// The cell value to display if `query` returns no result. + public record CellContent(string Query, string DefaultContent); +} diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 15b3fc1ab..514a9ec93 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -1,3 +1,5 @@ +using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; @@ -8,25 +10,33 @@ namespace Altinn.App.Core.Models.Layout; /// public record LayoutModel { + private readonly List _layouts; + private readonly Dictionary _layoutsLookup; + private readonly LayoutSetComponent _defaultLayoutSet; + /// - /// Dictionary to hold the different pages that are part of this LayoutModel + /// Constructor for the component model that wraps multiple layouts /// - public required IReadOnlyDictionary Pages { get; init; } + /// List of layouts we need + /// Optional default layout (if not just using the first) + public LayoutModel(List layouts, LayoutSet? defaultLayout) + { + _layouts = layouts; + _layoutsLookup = layouts.ToDictionary(l => l.Id); + _defaultLayoutSet = defaultLayout is not null ? _layoutsLookup[defaultLayout.Id] : layouts[0]; + } /// /// The default data type for the layout model /// - public required DataType DefaultDataType { get; init; } + public DataType DefaultDataType => _defaultLayoutSet.DefaultDataType; /// /// Get a specific component on a specific page. /// public BaseComponent GetComponent(string pageName, string componentId) { - if (!Pages.TryGetValue(pageName, out var page)) - { - throw new ArgumentException($"Unknown page name {pageName}"); - } + var page = _defaultLayoutSet.GetPage(pageName); if (!page.ComponentLookup.TryGetValue(componentId, out var component)) { @@ -41,7 +51,7 @@ public BaseComponent GetComponent(string pageName, string componentId) public IEnumerable GetComponents() { // Use a stack in order to implement a depth first search - var nodes = new Stack(Pages.Values); + var nodes = new Stack(_defaultLayoutSet.Pages); while (nodes.Count != 0) { var node = nodes.Pop(); @@ -51,4 +61,101 @@ public IEnumerable GetComponents() nodes.Push(n); } } + + /// + /// Generate a list of for all components in the layout model + /// taking repeating groups into account. + /// + /// The instance with data element information + /// The data model to use for repeating groups + /// + public async Task> GenerateComponentContexts(Instance instance, DataModel dataModel) + { + var pageContexts = new List(); + foreach (var page in _defaultLayoutSet.Pages) + { + pageContexts.Add( + await GenerateComponentContextsRecurs( + page, + dataModel, + _defaultLayoutSet.GetDefaultDataElementId(instance), + [] + ) + ); + } + + return pageContexts; + } + + private async Task GenerateComponentContextsRecurs( + BaseComponent component, + DataModel dataModel, + DataElementId defaultDataElementId, + int[]? indexes + ) + { + var children = new List(); + int? rowLength = null; + + if (component is SubFormComponent subFormComponent) + { + var layoutSetId = subFormComponent.LayoutSetId; + var layout = _layoutsLookup[layoutSetId]; + var dataElementsForSubForm = dataModel.Instance.Data.Where(d => d.DataType == layout.DefaultDataType.Id); + foreach (var dataElement in dataElementsForSubForm) + { + List subforms = new(); + + foreach (var page in layout.Pages) + { + subforms.Add(await GenerateComponentContextsRecurs(page, dataModel, dataElement, indexes: null)); + } + + children.Add(new ComponentContext(subFormComponent, null, null, dataElement, subforms)); + } + } + else if (component is RepeatingGroupComponent repeatingGroupComponent) + { + if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) + { + rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; + foreach (var index in Enumerable.Range(0, rowLength.Value)) + { + foreach (var child in repeatingGroupComponent.Children) + { + // concatenate [...indexes, index] + var subIndexes = new int[(indexes?.Length ?? 0) + 1]; + indexes.CopyTo(subIndexes.AsSpan()); + subIndexes[^1] = index; + + children.Add( + await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) + ); + } + } + } + } + else if (component is GroupComponent groupComponent) + { + foreach (var child in groupComponent.Children) + { + children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); + } + } + + var context = new ComponentContext( + component, + indexes?.Length > 0 ? indexes : null, + rowLength, + defaultDataElementId, + children + ); + + return context; + } + + internal DataElementId GetDefaultDataElementId(Instance instanceContext) + { + return _defaultLayoutSet.GetDefaultDataElementId(instanceContext); + } } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs new file mode 100644 index 000000000..e04b67902 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs @@ -0,0 +1,61 @@ +using Altinn.App.Core.Models.Layout.Components; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Models.Layout; + +/// +/// Wrapper class for a single layout set +/// +public record LayoutSetComponent +{ + private readonly Dictionary _pagesLookup; + private readonly List _pages; + + /// + /// Create a new layout + /// + public LayoutSetComponent(List pages, string id, DataType defaultDataType) + { + _pages = pages; + _pagesLookup = pages.ToDictionary(p => p.PageId); + Id = id; + DefaultDataType = defaultDataType; + } + + /// + /// Name of the layout set + /// + public string Id { get; } + + /// + /// The data type associated with this layout + /// + public DataType DefaultDataType { get; } + + /// + /// Get a single page by name + /// + public PageComponent GetPage(string pageName) + { + if (!_pagesLookup.TryGetValue(pageName, out var page)) + { + throw new ArgumentException($"Unknown page name {pageName}"); + } + return page; + } + + /// + /// Enumerate over all the pages in the layout + /// + public IEnumerable Pages => _pages; + + /// + /// Get the of the that is default for this layout + /// + public DataElementId GetDefaultDataElementId(Instance instance) + { + var dataType = DefaultDataType.Id; + return instance.Data.Find(d => d.DataType == dataType) + ?? throw new ArgumentException($"Data element with type {DefaultDataType} not found"); + } +} diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index e07f096b0..aea977255 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -272,7 +272,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt Expression? required = null; Expression? readOnly = null; // Custom properities for group - List? children = null; + List? childIDs = null; int maxCount = 1; // > 1 is repeating, but might not be specified for non-repeating groups // Custom properties for Summary string? componentRef = null; @@ -281,6 +281,11 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt List? literalOptions = null; OptionsSource? optionsSource = null; bool secure = false; + // Custom properties for subform + string? layoutSetId = null; + // List? tableColumns = null; + // bool showAddButton = true; + // bool showDeleteButton = true; // extra properties that are not stored in a specific class. Dictionary additionalProperties = new(); @@ -316,13 +321,13 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt // case "textresourcebindings": // break; case "children": - children = JsonSerializer + childIDs = JsonSerializer .Deserialize>(ref reader, options) ?.Select(GetIdWithoutMultiPageIndex) .ToList(); break; case "rows": - children = GridConfig.ReadGridChildren(ref reader, options); + childIDs = GridConfig.ReadGridChildren(ref reader, options); break; case "maxcount": maxCount = reader.GetInt32(); @@ -356,6 +361,19 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt case "secure": secure = reader.TokenType == JsonTokenType.True; break; + // subform + case "layoutsetid": + layoutSetId = reader.GetString(); + break; + // case "tablecolumns": + // tableColumns = JsonSerializer.Deserialize>(ref reader, options); + // break; + // case "showaddbutton": + // showAddButton = reader.TokenType != JsonTokenType.False; + // break; + // case "showdeletebutton": + // showDeleteButton = reader.TokenType != JsonTokenType.False; + // break; default: // store extra fields as json additionalProperties[propertyName] = reader.SkipReturnString(); @@ -368,10 +386,6 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt switch (type.ToLowerInvariant()) { case "repeatinggroup": - ThrowJsonExceptionIfNull( - children, - "Component with \"type\": \"Group\" requires a \"children\" property" - ); if (!(dataModelBindings?.ContainsKey("group") ?? false)) { throw new JsonException( @@ -384,7 +398,10 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt type, dataModelBindings, new List(), - children, + childIDs + ?? throw new JsonException( + "Component with \"type\": \"Group\" requires a \"children\" property" + ), maxCount, hidden ?? Expression.False, hiddenRow ?? Expression.False, @@ -396,7 +413,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt case "group": ThrowJsonExceptionIfNull( - children, + childIDs, "Component with \"type\": \"Group\" requires a \"children\" property" ); @@ -414,7 +431,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt type, dataModelBindings, new List(), - children, + childIDs, maxCount, hidden ?? Expression.False, hiddenRow ?? Expression.False, @@ -431,7 +448,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt type, dataModelBindings, new List(), - children, + childIDs, hidden ?? Expression.False, required ?? Expression.False, readOnly ?? Expression.False, @@ -445,7 +462,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt type, dataModelBindings, new List(), - children, + childIDs, hidden ?? Expression.False, required ?? Expression.False, readOnly ?? Expression.False, @@ -453,8 +470,16 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt ); return gridComponent; case "summary": - ValidateSummary(componentRef); - return new SummaryComponent(id, type, hidden ?? Expression.False, componentRef, additionalProperties); + return new SummaryComponent( + id, + type, + hidden ?? Expression.False, + componentRef + ?? throw new JsonException( + "Component with \"type\": \"Summary\" requires the \"componentRef\" property" + ), + additionalProperties + ); case "checkboxes": case "radiobuttons": case "dropdown": @@ -472,6 +497,20 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt secure, additionalProperties ); + case "subform": + return new SubFormComponent( + id, + type, + dataModelBindings, + layoutSetId ?? throw new JsonException("Subform requires a layoutSetId"), + // tableColumns ?? new(), + // showAddButton, + // showDeleteButton, + hidden ?? Expression.False, + required ?? Expression.False, + readOnly ?? Expression.False, + additionalProperties + ); } // Most components are handled as BaseComponent @@ -555,14 +594,6 @@ bool secure } } - private static void ValidateSummary([NotNull] string? componentRef) - { - if (componentRef is null) - { - throw new JsonException("Component with \"type\": \"Summary\" requires the \"componentRef\" property"); - } - } - /// /// Utility method to recduce so called Coginitve Complexity by writing if in the meth /// diff --git a/src/Altinn.App.Core/Models/LayoutSet.cs b/src/Altinn.App.Core/Models/LayoutSet.cs index 78e1f11ae..5b691eb22 100644 --- a/src/Altinn.App.Core/Models/LayoutSet.cs +++ b/src/Altinn.App.Core/Models/LayoutSet.cs @@ -8,20 +8,17 @@ public class LayoutSet /// /// LayoutsetId for layout. This is the foldername /// -#nullable disable - public string Id { get; set; } + public required string Id { get; set; } /// /// DataType for layout /// - public string DataType { get; set; } + public required string DataType { get; set; } /// /// List of tasks where layout should be used /// - public List Tasks { get; set; } - -#nullable restore + public List? Tasks { get; set; } /// /// The type description for the layout diff --git a/src/Altinn.App.Core/Models/LayoutSets.cs b/src/Altinn.App.Core/Models/LayoutSets.cs index b0fcbe00a..e49d4cd9a 100644 --- a/src/Altinn.App.Core/Models/LayoutSets.cs +++ b/src/Altinn.App.Core/Models/LayoutSets.cs @@ -8,5 +8,5 @@ public class LayoutSets /// /// Sets /// - public List? Sets { get; set; } + public required List Sets { get; set; } } diff --git a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj index ca6de8bff..442ec07b6 100644 --- a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj +++ b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj @@ -11,7 +11,6 @@ - diff --git a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs index c2d64af46..8950d76b8 100644 --- a/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs +++ b/test/Altinn.App.Api.Tests/CustomWebApplicationFactory.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Altinn.App.Api.Tests.Data; using Altinn.App.Api.Tests.Utils; +using Altinn.App.Common.Tests; using Altinn.App.Core.Configuration; using FluentAssertions; using Microsoft.AspNetCore.Hosting; @@ -129,30 +130,10 @@ public static void ConfigureFakeLogging(ILoggingBuilder builder, ITestOutputHelp LogLevel.Critical }; } - options.OutputFormatter = log => - $""" - [{ShortLogLevel(log.Level)}] {log.Category}: - {log.Message}{(log.Exception is not null ? "\n" : "")}{log.Exception} - - """; + options.OutputFormatter = FakeLoggerXunit.OutputFormatter; }); } - private static string ShortLogLevel(LogLevel logLevel) - { - return logLevel switch - { - LogLevel.Trace => "trac", - LogLevel.Debug => "debu", - LogLevel.Information => "info", - LogLevel.Warning => "warn", - LogLevel.Error => "erro", - LogLevel.Critical => "crit", - LogLevel.None => "none", - _ => "????", - }; - } - private void ConfigureFakeHttpClientHandler(IServiceCollection services) { // Remove existing IHttpClientFactory and HttpClient diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/Settings.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/default/Settings.json similarity index 100% rename from test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/Settings.json rename to test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/default/Settings.json diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/default/layouts/page.json similarity index 100% rename from test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layouts/page.json rename to test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/default/layouts/page.json diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layout-sets.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layout-sets.json new file mode 100644 index 000000000..960ed988c --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/ui/layout-sets.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://altinncdn.no/schemas/json/layout/layout-sets.schema.v1.json", + "sets":[ + { + "id": "default", + "dataType": "default", + "tasks": ["Task_1"] + } + ] +} diff --git a/test/Altinn.App.Common.Tests/Altinn.App.Common.Tests.csproj b/test/Altinn.App.Common.Tests/Altinn.App.Common.Tests.csproj index a9159603b..d294348ed 100644 --- a/test/Altinn.App.Common.Tests/Altinn.App.Common.Tests.csproj +++ b/test/Altinn.App.Common.Tests/Altinn.App.Common.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/test/Altinn.App.Common.Tests/FakeLoggerXunit.cs b/test/Altinn.App.Common.Tests/FakeLoggerXunit.cs new file mode 100644 index 000000000..fa698af0b --- /dev/null +++ b/test/Altinn.App.Common.Tests/FakeLoggerXunit.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit.Abstractions; + +namespace Altinn.App.Common.Tests; + +public static class FakeLoggerXunit +{ + public static IServiceCollection AddFakeLoggingWithXunit(this IServiceCollection services, ITestOutputHelper output) + { + services.AddFakeLogging(options => + { + options.OutputFormatter = OutputFormatter; + options.OutputSink = output.WriteLine; + }); + return services; + } + + public static FakeLogger Get(ITestOutputHelper output) + { + var options = new FakeLogCollectorOptions() + { + OutputFormatter = OutputFormatter, + OutputSink = output.WriteLine, + }; + var collector = FakeLogCollector.Create(options); + { } + ; + var logger = new FakeLogger(collector); + return logger; + } + + public static string OutputFormatter(FakeLogRecord log) + { + return $""" + [{ShortLogLevel(log.Level)}] {log.Category}: + {log.Message}{(log.Exception is not null ? "\n" : "")}{log.Exception} + + """; + } + + private static string ShortLogLevel(LogLevel logLevel) => + logLevel switch + { + LogLevel.Trace => "trac", + LogLevel.Debug => "debu", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "erro", + LogLevel.Critical => "crit", + LogLevel.None => "none", + _ => "????", + }; +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 7149b1698..2e01b14d8 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -72,15 +72,13 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app", }; var dataElement = new DataElement { DataType = "default", }; + var dataType = new DataType() { Id = "default" }; - var dataModel = DynamicClassBuilder.DataModelFromJsonDocument(instance, testCase.FormData, dataElement); + var dataModel = DynamicClassBuilder.DataAccessorFromJsonDocument(instance, testCase.FormData, dataElement); - var layoutModel = new LayoutModel() - { - DefaultDataType = new DataType() { Id = "default" }, - Pages = testCase.Layouts, - }; - var evaluatorState = new LayoutEvaluatorState(dataModel, layoutModel, _frontendSettings.Value, instance); + var layout = new LayoutSetComponent(testCase.Layouts.Values.ToList(), "layout", dataType); + var componentModel = new LayoutModel([layout], null); + var evaluatorState = new LayoutEvaluatorState(dataModel, componentModel, _frontendSettings.Value); _layoutInitializer .Setup(init => init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) @@ -89,9 +87,11 @@ private async Task RunExpressionValidationTest(string fileName, string folder) _appResources .Setup(ar => ar.GetValidationConfiguration("default")) .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); - _appResources.Setup(ar => ar.GetLayoutSetForTask(null!)).Returns(new LayoutSet() { DataType = "default", }); + _appResources + .Setup(ar => ar.GetLayoutSetForTask(null!)) + .Returns(new LayoutSet() { Id = "layout", DataType = "default", }); - var dataAccessor = new TestInstanceDataAccessor(instance) { { dataElement, dataModel } }; + var dataAccessor = new InstanceDataAccessorFake(instance) { { dataElement, dataModel } }; var validationIssues = await _validator.ValidateFormData(instance, dataElement, dataAccessor, "Task_1", null); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index 9bd0839d3..e9142019f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -31,20 +31,20 @@ public class ExpressionTestCaseRoot public string? Name { get; set; } [JsonPropertyName("expression")] - public Expression Expression { get; set; } = default!; + public Expression Expression { get; set; } [JsonPropertyName("context")] - public ComponentContextForTestSpec? Context { get; set; } = default!; + public ComponentContextForTestSpec? Context { get; set; } [JsonPropertyName("expects")] - public JsonElement Expects { get; set; } = default!; + public JsonElement Expects { get; set; } [JsonPropertyName("expectsFailure")] public string? ExpectsFailure { get; set; } [JsonPropertyName("layouts")] [JsonConverter(typeof(LayoutModelConverterFromObject))] - public IReadOnlyDictionary Layouts { get; set; } = default!; + public IReadOnlyDictionary? Layouts { get; set; } [JsonPropertyName("dataModel")] public JsonElement? DataModel { get; set; } @@ -100,15 +100,15 @@ public class ComponentContextForTestSpec public IEnumerable ChildContexts { get; set; } = Enumerable.Empty(); - public ComponentContext ToContext(LayoutModel model, LayoutEvaluatorState state) + public ComponentContext ToContext(LayoutModel? model, LayoutEvaluatorState state) { - var component = model.GetComponent(CurrentPageName, ComponentId); + var component = model?.GetComponent(CurrentPageName, ComponentId); return new ComponentContext( component, RowIndices, rowLength: component is RepeatingGroupComponent ? 0 : null, // TODO: get from data model, but currently not important for tests - state.GetDefaultElementId(), + state.GetDefaultDataElementId(), ChildContexts.Select(c => c.ToContext(model, state)) ); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index ea69aee59..092786acd 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -60,19 +60,16 @@ private async Task RunTestCase(string testName, string folder) _output.WriteLine($"{test.Filename} in {test.Folder}"); _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); - var componentModel = new LayoutModel() - { - DefaultDataType = new DataType() { Id = "default", }, - Pages = test.Layouts, - }; + var dataType = new DataType() { Id = "default", }; + var layout = new LayoutSetComponent(test.Layouts!.Values.ToList(), "layout", dataType); + var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument( + DynamicClassBuilder.DataAccessorFromJsonDocument( test.Instance, test.DataModel ?? JsonDocument.Parse("{}").RootElement ), componentModel, test.FrontEndSettings ?? new(), - test.Instance ?? new(), test.GatewayAction, test.ProfileSettings?.Language ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs index 505ae8bcb..b3bd860a6 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs @@ -66,19 +66,16 @@ private async Task RunTestCase(string filename, string folder) _output.WriteLine(test.FullPath); var instance = new Instance() { Data = [] }; - var componentModel = new LayoutModel() - { - DefaultDataType = new DataType() { Id = "default" }, - Pages = test.Layouts, - }; + var dataType = new DataType() { Id = "default" }; + var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument( + DynamicClassBuilder.DataAccessorFromJsonDocument( instance, test.DataModel ?? JsonDocument.Parse("{}").RootElement ), componentModel, - new(), - instance + new() ); test.ParsingException.Should().BeNull("Loading of test failed"); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 213dd164f..8b784743a 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -164,23 +164,25 @@ private async Task RunTestCase(string testName, string folder) _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); - var dataModel = test.DataModels is null - ? DynamicClassBuilder.DataModelFromJsonDocument( + var dataAccessor = test.DataModels is null + ? DynamicClassBuilder.DataAccessorFromJsonDocument( test.Instance, test.DataModel ?? JsonDocument.Parse("{}").RootElement ) - : DynamicClassBuilder.DataModelFromJsonDocument(test.Instance, test.DataModels); + : DynamicClassBuilder.DataAccessorFromJsonDocument(test.Instance, test.DataModels); - var componentModel = new LayoutModel() + var dataType = new DataType() { Id = "default" }; + + LayoutModel? componentModel = null; + if (test.Layouts is not null) { - DefaultDataType = new DataType() { Id = "default", }, - Pages = test.Layouts, - }; + var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + componentModel = new LayoutModel([layout], null); + } var state = new LayoutEvaluatorState( - dataModel, + dataAccessor, componentModel, test.FrontEndSettings ?? new FrontEndSettings(), - test.Instance, test.GatewayAction, test.ProfileSettings?.Language ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index f7c30a995..a816981e9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -32,20 +32,21 @@ public async Task Simple_Theory(string testName, string folder) Func act = async () => { var test = JsonSerializer.Deserialize(testCase.RawJson!, _jsonSerializerOptions)!; - var componentModel = new LayoutModel() + var dataType = new DataType() { Id = "default", }; + LayoutModel? componentModel = null; + if (test.Layouts is not null) { - DefaultDataType = new DataType() { Id = "default", }, - Pages = test.Layouts, - }; + var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + componentModel = new LayoutModel([layout], null); + } var state = new LayoutEvaluatorState( - DynamicClassBuilder.DataModelFromJsonDocument( + DynamicClassBuilder.DataAccessorFromJsonDocument( test.Instance, test.DataModel ?? JsonDocument.Parse("{}").RootElement ), componentModel, - test.FrontEndSettings ?? new(), - test.Instance ?? new() + test.FrontEndSettings ?? new() ); await ExpressionEvaluator.EvaluateExpression( state, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 498eed154..02f253622 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -72,22 +72,20 @@ public static async Task GetLayoutModelTools(object model, appModel.Setup(am => am.GetModelType(ClassRef)).Returns(modelType); var resources = new Mock(); - var pages = new Dictionary(); + var pages = new List(); var layoutsPath = Path.Join("LayoutExpressions", "FullTests", folder); foreach (var layoutFile in Directory.GetFiles(layoutsPath, "*.json")) { - var layout = await File.ReadAllBytesAsync(layoutFile); + var layoutBytes = await File.ReadAllBytesAsync(layoutFile); string pageName = layoutFile.Replace(layoutsPath + "/", string.Empty).Replace(".json", string.Empty); PageComponentConverter.SetAsyncLocalPageName(pageName); - pages[pageName] = JsonSerializer.Deserialize(layout.RemoveBom(), _jsonSerializerOptions)!; + pages.Add(JsonSerializer.Deserialize(layoutBytes.RemoveBom(), _jsonSerializerOptions)!); } - var layoutModel = new LayoutModel() - { - DefaultDataType = new DataType() { Id = DataTypeId, }, - Pages = pages - }; + var dataType = new DataType() { Id = DataTypeId, }; + var layout = new LayoutSetComponent(pages, "layout", dataType); + var layoutModel = new LayoutModel([layout], null); resources.Setup(r => r.GetLayoutModelForTask(TaskId)).Returns(layoutModel); @@ -102,7 +100,7 @@ public static async Task GetLayoutModelTools(object model, using var scope = serviceProvider.CreateScope(); var initializer = scope.ServiceProvider.GetRequiredService(); - var dataAccessor = new TestInstanceDataAccessor(_instance) { { _dataElement, model } }; + var dataAccessor = new InstanceDataAccessorFake(_instance) { { _dataElement, model } }; return await initializer.Init(dataAccessor, TaskId); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index eea344a5f..00b37d8e9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -51,8 +51,16 @@ public async Task RemoveData_WhenPageExpressionIsTrue() .Should() .BeEquivalentTo( [ - new DataReference() { Field = "some.data.binding3", DataElementId = state.GetDefaultElementId() }, - new DataReference() { Field = "some.data.binding2", DataElementId = state.GetDefaultElementId() } + new DataReference() + { + Field = "some.data.binding3", + DataElementId = state.GetDefaultDataElementId() + }, + new DataReference() + { + Field = "some.data.binding2", + DataElementId = state.GetDefaultDataElementId() + } ] ); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index c391bc46c..255e01836 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -49,16 +49,36 @@ public async Task RemoveWholeGroup() .Should() .BeEquivalentTo( [ - new DataReference { Field = "some.data[0].binding", DataElementId = state.GetDefaultElementId() }, + new DataReference + { + Field = "some.data[0].binding", + DataElementId = state.GetDefaultDataElementId() + }, new DataReference() { Field = "some.data[0].binding2", - DataElementId = state.GetDefaultElementId() + DataElementId = state.GetDefaultDataElementId() + }, + new DataReference + { + Field = "some.data[0].binding3", + DataElementId = state.GetDefaultDataElementId() + }, + new DataReference + { + Field = "some.data[1].binding", + DataElementId = state.GetDefaultDataElementId() + }, + new DataReference + { + Field = "some.data[1].binding2", + DataElementId = state.GetDefaultDataElementId() }, - new DataReference { Field = "some.data[0].binding3", DataElementId = state.GetDefaultElementId() }, - new DataReference { Field = "some.data[1].binding", DataElementId = state.GetDefaultElementId() }, - new DataReference { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() }, - new DataReference { Field = "some.data[1].binding3", DataElementId = state.GetDefaultElementId() } + new DataReference + { + Field = "some.data[1].binding3", + DataElementId = state.GetDefaultDataElementId() + } ] ); @@ -99,7 +119,13 @@ public async Task RemoveSingleRow() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data[1].binding2", DataElementId = state.GetDefaultElementId() }] + [ + new DataReference() + { + Field = "some.data[1].binding2", + DataElementId = state.GetDefaultDataElementId() + } + ] ); } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index 3efc31eaa..f8dfcadb2 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -54,7 +54,7 @@ public async Task RemoveRowDataFromGroup() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultElementId() }] + [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultDataElementId() }] ); // Verify before removing data @@ -115,7 +115,7 @@ public async Task RemoveRowFromGroup() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultElementId() }] + [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultDataElementId() }] ); // Verify before removing data diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs index bfcbf90be..3e60bf10b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs @@ -135,25 +135,28 @@ public static object DataObjectFromJsonDocument(JsonElement doc) return instance; } - public static DataModel DataModelFromJsonDocument( + public static IInstanceDataAccessor DataAccessorFromJsonDocument( Instance instance, JsonElement doc, DataElement? dataElement = null ) { object data = DataObjectFromJsonDocument(doc); - var dataAccessor = new TestInstanceDataAccessor(instance) { { dataElement, data } }; - return new DataModel(dataAccessor); + var dataAccessor = new InstanceDataAccessorFake(instance) { { dataElement, data } }; + return dataAccessor; } - public static DataModel DataModelFromJsonDocument(Instance instance, List dataModels) + public static IInstanceDataAccessor DataAccessorFromJsonDocument( + Instance instance, + List dataModels + ) { - var dataAccessor = new TestInstanceDataAccessor(instance); + var dataAccessor = new InstanceDataAccessorFake(instance); foreach (var pair in dataModels) { dataAccessor.Add(pair.DataElement, DataObjectFromJsonDocument(pair.Data)); } - return new DataModel(dataAccessor); + return dataAccessor; } } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs similarity index 51% rename from test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs rename to test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 8fd146289..cb99a3e0a 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/TestInstanceDataAccessor.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -5,10 +5,13 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; -public class TestInstanceDataAccessor : IInstanceDataAccessor, IEnumerable> +public class InstanceDataAccessorFake : IInstanceDataAccessor, IEnumerable> { - public TestInstanceDataAccessor(Instance instance) + private readonly ApplicationMetadata? _applicationMetadata; + + public InstanceDataAccessorFake(Instance instance, ApplicationMetadata? applicationMetadata = null) { + _applicationMetadata = applicationMetadata; Instance = instance; Instance.Data ??= new(); } @@ -27,7 +30,28 @@ public void Add(DataElement? dataElement, object data) } _dataById.Add(dataElement, data); - _dataByType.TryAdd(dataElement.DataType, data); + if (_applicationMetadata is not null) + { + var dataType = + _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType) + ?? throw new ArgumentException($"Data type {dataElement.DataType} not found in application metadata"); + if (dataType.AppLogic is not null) + { + if (dataType.AppLogic.ClassRef != data.GetType().FullName) + throw new InvalidOperationException( + $"Data object registered for {dataElement.DataType} is not of type {dataType.AppLogic.ClassRef ?? "NULL"} as specified in applicationmetadata" + ); + if (dataType.MaxCount == 1) + { + _dataByType[dataType.Id] = data; + } + } + } + else + { + // We don't have application metadata, so just add the first element we see + _dataByType.TryAdd(dataElement.DataType, data); + } } public Instance Instance { get; } @@ -50,6 +74,7 @@ public Task GetData(DataElementId dataElementId) IEnumerator IEnumerable.GetEnumerator() { + // We implement IEnumerable so that we can use the collection initializer syntax return GetEnumerator(); } } From dea4fe9a6952ea59322b7180ad83c1147a9eb6bd Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 01:33:24 +0200 Subject: [PATCH 26/63] Fix tests after merge with main --- ...alidTestValue_ReturnsConflict.verified.txt | 5 +- .../Controllers/DataController_PatchTests.cs | 13 ++-- ...dator_ReturnsValidationErrors.verified.txt | 68 +++++++------------ ...sNext_PdfFails_DataIsUnlocked.verified.txt | 62 +++++++---------- .../Controllers/ProcessControllerTests.cs | 2 +- .../PatchServiceTests.Test_Ok.verified.txt | 8 +-- .../Internal/Process/ProcessNavigatorTests.cs | 7 +- 7 files changed, 67 insertions(+), 98 deletions(-) diff --git a/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt b/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt index ae63c500c..00200e139 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt @@ -5,9 +5,6 @@ Tags: [ { instance.guid: Guid_1 - }, - { - result: error } ], IdFormat: W3C @@ -69,4 +66,4 @@ } ], Metrics: [] -} \ No newline at end of file +} diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 6675c67b3..699c1ab3c 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -691,7 +691,7 @@ public async Task SetAttributeTagPropertyToEmpty_ReturnsCorrectDataModel() var pointer = JsonPointer.Create("melding", "tag-with-attribute"); var createFirstElementPatch = new JsonPatch( PatchOperation.Test(pointer, JsonNode.Parse("null")), - PatchOperation.Add(pointer, JsonNode.Parse("""{"value": "" }""")) + PatchOperation.Add(pointer, JsonNode.Parse("""{"value": "test" }""")) ); var (_, _, firstResponse) = await CallPatchApi( @@ -701,16 +701,17 @@ public async Task SetAttributeTagPropertyToEmpty_ReturnsCorrectDataModel() ); var firstData = firstResponse.NewDataModel.Should().BeOfType().Which; - var firstListItem = firstData.GetProperty("melding").GetProperty("tag-with-attribute"); - firstListItem.ValueKind.Should().Be(JsonValueKind.Null); + var firstListItem = firstData.GetProperty("melding").GetProperty("tag-with-attribute").GetProperty("value"); + firstListItem.ValueKind.Should().Be(JsonValueKind.String); + firstListItem.GetString().Should().Be("test"); var addValuePatch = new JsonPatch( - PatchOperation.Test(pointer, JsonNode.Parse("null")), - PatchOperation.Add(pointer.Combine("value"), JsonNode.Parse("null")) + PatchOperation.Test(pointer, JsonNode.Parse("""{"orid":34730,"value":"test"}""")), + PatchOperation.Add(pointer.Combine("value"), JsonNode.Parse("\"\"")) ); var (_, _, secondResponse) = await CallPatchApi(addValuePatch, null, HttpStatusCode.OK); var secondData = secondResponse.NewDataModel.Should().BeOfType().Which; - var secondValue = secondData.GetProperty("melding").GetProperty("name"); + var secondValue = secondData.GetProperty("melding").GetProperty("tag-with-attribute"); secondValue.ValueKind.Should().Be(JsonValueKind.Null); } diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index 071e38c22..ce1503c9a 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -8,6 +8,14 @@ ActivityName: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSet, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSets, + IdFormat: W3C + }, { ActivityName: ApplicationMetadata.Service.GetLayoutSets, IdFormat: W3C @@ -102,19 +110,7 @@ IdFormat: W3C }, { - ActivityName: Validation.RunDataElementValidator, - Tags: [ - { - validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* - }, - { - validator.type: DefaultDataElementValidator - } - ], - IdFormat: W3C - }, - { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { validator.source: Required @@ -126,7 +122,7 @@ IdFormat: W3C }, { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { validator.source: Expression @@ -138,73 +134,61 @@ IdFormat: W3C }, { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { - validator.source: DataAnnotations - }, - { - validator.type: DataAnnotationValidator - } - ], - IdFormat: W3C - }, - { - ActivityName: Validation.RunFormDataValidator, - Tags: [ - { - validator.source: test-source + validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* }, { - validator.type: IFormDataValidatorProxy + validator.type: TaskValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.RunTaskValidator, + ActivityName: Validation.RunValidator, Tags: [ { - validator.source: Altinn.App.Core.Features.Validation.Default.LegacyIInstanceValidatorTaskValidator + validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* }, { - validator.type: LegacyIInstanceValidatorTaskValidator + validator.type: DataElementValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.RunTaskValidator, + ActivityName: Validation.RunValidator, Tags: [ { - validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* + validator.source: DataAnnotations }, { - validator.type: DefaultTaskValidator + validator.type: FormDataValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.ValidateDataElement, + ActivityName: Validation.RunValidator, Tags: [ { - data.guid: Guid_3 + validator.source: Not a valid validation source }, { - instance.guid: Guid_1 + validator.type: FormDataValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.ValidateFormData, + ActivityName: Validation.RunValidator, Tags: [ { - data.guid: Guid_3 + validator.source: test-source }, { - instance.guid: Guid_1 + validator.type: FormDataValidatorWrapper } ], IdFormat: W3C @@ -223,4 +207,4 @@ } ], Metrics: [] -} \ No newline at end of file +} diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index c78f6e9fe..a01da51a9 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -8,6 +8,14 @@ ActivityName: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSet, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSets, + IdFormat: W3C + }, { ActivityName: ApplicationMetadata.Service.GetLayoutSets, IdFormat: W3C @@ -197,19 +205,7 @@ IdFormat: W3C }, { - ActivityName: Validation.RunDataElementValidator, - Tags: [ - { - validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* - }, - { - validator.type: DefaultDataElementValidator - } - ], - IdFormat: W3C - }, - { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { validator.source: Required @@ -221,7 +217,7 @@ IdFormat: W3C }, { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { validator.source: Expression @@ -233,61 +229,49 @@ IdFormat: W3C }, { - ActivityName: Validation.RunFormDataValidator, + ActivityName: Validation.RunValidator, Tags: [ { - validator.source: DataAnnotations - }, - { - validator.type: DataAnnotationValidator - } - ], - IdFormat: W3C - }, - { - ActivityName: Validation.RunTaskValidator, - Tags: [ - { - validator.source: Altinn.App.Core.Features.Validation.Default.LegacyIInstanceValidatorTaskValidator + validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* }, { - validator.type: LegacyIInstanceValidatorTaskValidator + validator.type: TaskValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.RunTaskValidator, + ActivityName: Validation.RunValidator, Tags: [ { - validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* + validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* }, { - validator.type: DefaultTaskValidator + validator.type: DataElementValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.ValidateDataElement, + ActivityName: Validation.RunValidator, Tags: [ { - data.guid: Guid_3 + validator.source: DataAnnotations }, { - instance.guid: Guid_1 + validator.type: FormDataValidatorWrapper } ], IdFormat: W3C }, { - ActivityName: Validation.ValidateFormData, + ActivityName: Validation.RunValidator, Tags: [ { - data.guid: Guid_3 + validator.source: Not a valid validation source }, { - instance.guid: Guid_1 + validator.type: FormDataValidatorWrapper } ], IdFormat: W3C @@ -306,4 +290,4 @@ } ], Metrics: [] -} \ No newline at end of file +} diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs index 70b7dc5a7..58cf76141 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs @@ -33,7 +33,7 @@ public class ProcessControllerTests : ApiTestBase, IClassFixture( - async () => await processNavigator.GetNextTask(new Instance(), "Task_Sign1", "sign") + async () => await processNavigator.GetNextTask(instance, "Task_Sign1", "sign") ); } From 85fc76c48b8afff2ba2031b0e1c1ac728d8e3ecd Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 08:05:57 +0200 Subject: [PATCH 27/63] Update storage.interfaces --- src/Altinn.App.Api/Altinn.App.Api.csproj | 2 +- src/Altinn.App.Api/Controllers/DataController.cs | 4 ++-- src/Altinn.App.Core/Altinn.App.Core.csproj | 2 +- .../Internal/Expressions/LayoutEvaluator.cs | 3 ++- .../Controllers/DataController_PatchTests.cs | 3 +-- test/Altinn.App.Api.Tests/OpenApi/swagger.json | 9 +++++++-- test/Altinn.App.Api.Tests/OpenApi/swagger.yaml | 8 ++++++-- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Altinn.App.Api/Altinn.App.Api.csproj b/src/Altinn.App.Api/Altinn.App.Api.csproj index 3fa85d6c4..fbe4c1efe 100644 --- a/src/Altinn.App.Api/Altinn.App.Api.csproj +++ b/src/Altinn.App.Api/Altinn.App.Api.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 469761c67..1332d1ec8 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -172,7 +172,7 @@ [FromQuery] string dataType if (dataTypeFromMetadata.AppLogic is not null) { - if (!dataTypeFromMetadata.AppLogic.AllowUserCreate && !UserHasValidOrgClaim()) + if (dataTypeFromMetadata.AppLogic.DisallowUserCreate && !UserHasValidOrgClaim()) { return BadRequest($"Element type `{dataType}` cannot be manually created."); } @@ -660,7 +660,7 @@ [FromRoute] Guid dataGuid if ( dataType.AppLogic?.ClassRef is not null - && !dataType.AppLogic.AllowUserDelete + && dataType.AppLogic.DisallowUserDelete && !UserHasValidOrgClaim() ) { diff --git a/src/Altinn.App.Core/Altinn.App.Core.csproj b/src/Altinn.App.Core/Altinn.App.Core.csproj index 327ef022b..cf24e3dd4 100644 --- a/src/Altinn.App.Core/Altinn.App.Core.csproj +++ b/src/Altinn.App.Core/Altinn.App.Core.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 0db558ad4..95724f713 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -152,6 +152,7 @@ private static async Task RunLayoutValidationsForRequiredRecurs( ComponentContext context ) { + ArgumentNullException.ThrowIfNull(context.Component); var hidden = await context.IsHidden(state); if (!hidden) { @@ -161,7 +162,7 @@ ComponentContext context } var required = await ExpressionEvaluator.EvaluateBooleanExpression(state, context, "required", false); - if (required && context.Component is not null) + if (required) { foreach (var (bindingName, binding) in context.Component.DataModelBindings) { diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 699c1ab3c..5841f0cf1 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -200,8 +200,7 @@ public async Task MultiplePatches_AppliesCorrectly() AppLogic = new() { ClassRef = - "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", - AllowUserCreate = true, // We use api to initialize this as a user. + "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema" }, } ); diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index ecd2fa807..9306567e3 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -5051,10 +5051,10 @@ "autoDeleteOnProcessEnd": { "type": "boolean" }, - "allowUserCreate": { + "disallowUserCreate": { "type": "boolean" }, - "allowUserDelete": { + "disallowUserDelete": { "type": "boolean" }, "allowInSubform": { @@ -5156,6 +5156,11 @@ "copyInstanceSettings": { "$ref": "#/components/schemas/CopyInstanceSettings" }, + "storageContainerNumber": { + "type": "integer", + "format": "int32", + "nullable": true + }, "disallowUserInstantiation": { "type": "boolean" }, diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 56347fb26..5e57ad2af 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -3100,9 +3100,9 @@ components: type: boolean autoDeleteOnProcessEnd: type: boolean - allowUserCreate: + disallowUserCreate: type: boolean - allowUserDelete: + disallowUserDelete: type: boolean allowInSubform: type: boolean @@ -3176,6 +3176,10 @@ components: $ref: '#/components/schemas/MessageBoxConfig' copyInstanceSettings: $ref: '#/components/schemas/CopyInstanceSettings' + storageContainerNumber: + type: integer + format: int32 + nullable: true disallowUserInstantiation: type: boolean id: From 6d3fb4ec5c203f60d7a88db059e3e3199f10010a Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 10:34:10 +0200 Subject: [PATCH 28/63] Fix tests after storage upgrade --- src/Altinn.App.Core/Models/Layout/LayoutModel.cs | 4 ++-- .../Models/Layout/LayoutSetComponent.cs | 2 +- .../Controllers/DataController_UserAccessTests.cs | 12 ++++++------ .../config/applicationmetadata.json | 8 ++++---- ...pped-properties.applicationmetadata.expected.json | 1 + 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 514a9ec93..73dcd7510 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -8,7 +8,7 @@ namespace Altinn.App.Core.Models.Layout; /// /// Class for handling a full layout/layoutset /// -public record LayoutModel +public class LayoutModel { private readonly List _layouts; private readonly Dictionary _layoutsLookup; @@ -125,7 +125,7 @@ private async Task GenerateComponentContextsRecurs( { // concatenate [...indexes, index] var subIndexes = new int[(indexes?.Length ?? 0) + 1]; - indexes.CopyTo(subIndexes.AsSpan()); + indexes?.CopyTo(subIndexes.AsSpan()); subIndexes[^1] = index; children.Add( diff --git a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs index e04b67902..aa1e32c9a 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs @@ -56,6 +56,6 @@ public DataElementId GetDefaultDataElementId(Instance instance) { var dataType = DefaultDataType.Id; return instance.Data.Find(d => d.DataType == dataType) - ?? throw new ArgumentException($"Data element with type {DefaultDataType} not found"); + ?? throw new ArgumentException($"Data element with type {dataType} not found"); } } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs index ad4dacab5..4512074ce 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs @@ -28,10 +28,10 @@ public DataController_UserAccessTests(WebApplicationFactory factory, IT } [Theory] - [InlineData("userInteractionUnspecified", null, HttpStatusCode.BadRequest)] + [InlineData("userInteractionUnspecified", null, HttpStatusCode.Created)] [InlineData("userInteractionUnspecified", OrgId, HttpStatusCode.Created)] - [InlineData("userCreateEnabled", null, HttpStatusCode.Created)] - [InlineData("userCreateEnabled", OrgId, HttpStatusCode.Created)] + [InlineData("disallowUserCreate", null, HttpStatusCode.BadRequest)] + [InlineData("disallowUserCreate", OrgId, HttpStatusCode.Created)] public async Task CreateDataElement_ImplementsAndValidates_AllowUserCreateProperty( string dataModelId, string? tokenOrgClaim, @@ -52,10 +52,10 @@ HttpStatusCode expectedStatusCode } [Theory] - [InlineData("userInteractionUnspecified", null, HttpStatusCode.BadRequest)] + [InlineData("userInteractionUnspecified", null, HttpStatusCode.OK)] [InlineData("userInteractionUnspecified", OrgId, HttpStatusCode.OK)] - [InlineData("userDeleteEnabled", null, HttpStatusCode.OK)] - [InlineData("userDeleteEnabled", OrgId, HttpStatusCode.OK)] + [InlineData("disallowUserDelete", null, HttpStatusCode.OK)] + [InlineData("disallowUserDelete", OrgId, HttpStatusCode.OK)] public async Task DeleteDataElement_ImplementsAndValidates_AllowUserDeleteProperty( string dataModelId, string? tokenOrgClaim, diff --git a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json index 71b8bcc9c..724329672 100644 --- a/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json +++ b/test/Altinn.App.Api.Tests/Data/apps/tdd/contributer-restriction/config/applicationmetadata.json @@ -78,26 +78,26 @@ ] }, { - "id": "userCreateEnabled", + "id": "disallowUserCreate", "allowedContentTypes": [ "application/xml" ], "maxCount": 10, "appLogic": { "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", - "allowUserCreate": true + "disallowUserCreate": true }, "taskId": "Task_1" }, { - "id": "userDeleteEnabled", + "id": "disallowUserDelete", "allowedContentTypes": [ "application/xml" ], "maxCount": 10, "appLogic": { "ClassRef": "Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema", - "allowUserDelete": true + "diallowUserDelete": true }, "taskId": "Task_1" } diff --git a/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json b/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json index acd005aca..2f428d7fa 100644 --- a/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json +++ b/test/Altinn.App.Core.Tests/Internal/App/TestData/AppMetadata/unmapped-properties.applicationmetadata.expected.json @@ -75,6 +75,7 @@ "EFormidling": null, "MessageBoxConfig": null, "CopyInstanceSettings": null, + "StorageContainerNumber": null, "DisallowUserInstantiation": false, "Created": "2019-09-16T22:22:22", "CreatedBy": "username", From 9a3a35a4f33d2a474d70d1a91ae79b62be4da034 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 15:10:20 +0200 Subject: [PATCH 29/63] Add LayoutId to required validation descriptions --- .../Implementation/AppResourcesSI.cs | 2 +- .../Internal/Expressions/LayoutEvaluator.cs | 5 +-- .../Models/Layout/Components/BaseComponent.cs | 17 +++++----- .../Models/Layout/Components/PageComponent.cs | 8 +++++ .../Models/Layout/PageComponentConverter.cs | 31 +++++++++++++------ .../Controllers/DataController_PatchTests.cs | 2 +- .../LayoutModelConverterFromObject.cs | 3 +- .../FullTests/LayoutTestUtils.cs | 2 +- 8 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 501b412a7..9a196a53c 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -344,7 +344,7 @@ private LayoutSetComponent LoadLayout(LayoutSet layoutSet, List dataTy { var pageBytes = File.ReadAllBytes(Path.Join(folder, page + ".json")); // Set the PageName using AsyncLocal before deserializing. - PageComponentConverter.SetAsyncLocalPageName(page); + PageComponentConverter.SetAsyncLocalPageName(layoutSet.Id, page); pages.Add( System.Text.Json.JsonSerializer.Deserialize( pageBytes.RemoveBom(), diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 95724f713..fb1a6e6f6 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -166,7 +166,8 @@ ComponentContext context { foreach (var (bindingName, binding) in context.Component.DataModelBindings) { - if (await state.GetModelData(binding, context.DataElementId, context.RowIndices) is null) + var value = await state.GetModelData(binding, context.DataElementId, context.RowIndices); + if (value is null) { var field = await state.AddInidicies(binding, context); validationIssues.Add( @@ -176,7 +177,7 @@ ComponentContext context DataElementId = field.DataElementId.ToString(), Field = field.Field, Description = - $"{field.Field} is required in component with id {context.Component.Id} for binding {bindingName}", + $"{field.Field} is required in component with id {context.Component.LayoutId}.{context.Component.PageId}.{context.Component.Id} for binding {bindingName}", Code = "required", } ); diff --git a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs index 595e03e9a..6a37422af 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs @@ -43,14 +43,15 @@ public BaseComponent( /// /// Get the page for the component /// - public string PageId - { - get - { - //Get the Id of the first component without a parent. - return Parent?.PageId ?? Id; - } - } + public virtual string PageId => + Parent?.PageId ?? throw new InvalidOperationException("Component is not part of a page"); + + /// + /// Get the layout + /// + /// + public virtual string LayoutId => + Parent?.LayoutId ?? throw new InvalidOperationException("Component is not part of a layout"); /// /// Component type as written in the json file diff --git a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs index 505f040f4..49949977b 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs @@ -14,6 +14,7 @@ public record PageComponent : GroupComponent /// public PageComponent( string id, + string layoutId, List children, Dictionary componentLookup, Expression hidden, @@ -23,9 +24,16 @@ public PageComponent( ) : base(id, "page", null, children, null, hidden, required, readOnly, extra) { + LayoutId = layoutId; ComponentLookup = componentLookup; } + /// + public override string PageId => Id; + + /// + public override string LayoutId { get; } + /// /// Helper dictionary to find components without traversing childern. /// diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index aea977255..1eb6e3b07 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -19,7 +19,7 @@ namespace Altinn.App.Core.Models.Layout; /// public class PageComponentConverter : JsonConverter { - private static readonly AsyncLocal _pageName = new(); + private static readonly AsyncLocal<(string layoutId, string pageName)?> _asyncLocal = new(); /// /// Store pageName to be used for deserialization @@ -30,25 +30,32 @@ public class PageComponentConverter : JsonConverter /// /// This uses a AsyncLocal to pass the pageName as an additional parameter /// - public static void SetAsyncLocalPageName(string pageName) + public static void SetAsyncLocalPageName(string layoutId, string pageName) { - _pageName.Value = pageName; + _asyncLocal.Value = (layoutId, pageName); } /// public override PageComponent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Try to get pagename from metadata in this.AddPageName - var pageName = _pageName.Value ?? "UnknownPageName"; - _pageName.Value = null; + var pageName = _asyncLocal.Value?.pageName ?? "UnknownPageName"; + var layoutId = _asyncLocal.Value?.layoutId ?? "UnknownLayoutSetId"; - return ReadNotNull(ref reader, pageName, options); + _asyncLocal.Value = null; + + return ReadNotNull(ref reader, pageName, layoutId, options); } /// /// Similar to read, but not nullable, and no pageName hack. /// - public PageComponent ReadNotNull(ref Utf8JsonReader reader, string pageName, JsonSerializerOptions options) + public PageComponent ReadNotNull( + ref Utf8JsonReader reader, + string pageName, + string layoutId, + JsonSerializerOptions options + ) { if (reader.TokenType != JsonTokenType.StartObject) { @@ -72,7 +79,7 @@ public PageComponent ReadNotNull(ref Utf8JsonReader reader, string pageName, Jso reader.Read(); if (propertyName == "data") { - page = ReadData(ref reader, pageName, options); + page = ReadData(ref reader, pageName, layoutId, options); } else { @@ -87,7 +94,12 @@ public PageComponent ReadNotNull(ref Utf8JsonReader reader, string pageName, Jso return page; } - private PageComponent ReadData(ref Utf8JsonReader reader, string pageName, JsonSerializerOptions options) + private PageComponent ReadData( + ref Utf8JsonReader reader, + string pageName, + string layoutId, + JsonSerializerOptions options + ) { if (reader.TokenType != JsonTokenType.StartObject) { @@ -155,6 +167,7 @@ private PageComponent ReadData(ref Utf8JsonReader reader, string pageName, JsonS return new PageComponent( pageName, + layoutId, layout, componentLookup, hidden ?? Expression.False, diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 5841f0cf1..8b5a2bc0a 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -303,7 +303,7 @@ public async Task NullName_ReturnsOkAndValidationError() requiredName.Field.Should().Be("melding.name"); requiredName .Description.Should() - .Be("melding.name is required in component with id name for binding simpleBinding"); + .Be("melding.name is required in component with id default.page.name for binding simpleBinding"); // Run full validation to see that result is the same using var client = GetRootedClient(Org, App, UserId, null); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs index 7e6ecc993..54c61702a 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs @@ -49,10 +49,9 @@ JsonSerializerOptions options ); reader.Read(); - PageComponentConverter.SetAsyncLocalPageName(pageName); var converter = new PageComponentConverter(); - pages[pageName] = converter.ReadNotNull(ref reader, pageName, options); + pages[pageName] = converter.ReadNotNull(ref reader, pageName, "test-layout", options); } return pages; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 02f253622..c3ae025a0 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -79,7 +79,7 @@ public static async Task GetLayoutModelTools(object model, var layoutBytes = await File.ReadAllBytesAsync(layoutFile); string pageName = layoutFile.Replace(layoutsPath + "/", string.Empty).Replace(".json", string.Empty); - PageComponentConverter.SetAsyncLocalPageName(pageName); + PageComponentConverter.SetAsyncLocalPageName("layout", pageName); pages.Add(JsonSerializer.Deserialize(layoutBytes.RemoveBom(), _jsonSerializerOptions)!); } From 1d30664dd833d92e45b8be223d4d455131b4c937 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 6 Sep 2024 15:32:06 +0200 Subject: [PATCH 30/63] Add tests for subform validation --- .../Models/Layout/Components/PageComponent.cs | 9 +- .../SubForm/SubFormTests.Test1.verified.txt | 42 +++ .../FullTests/SubForm/SubFormTests.cs | 252 ++++++++++++++++++ 3 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt create mode 100644 test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs diff --git a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs index 49949977b..a090ca51f 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs @@ -9,6 +9,8 @@ namespace Altinn.App.Core.Models.Layout.Components; [JsonConverter(typeof(PageComponentConverter))] public record PageComponent : GroupComponent { + private readonly string _layoutId; + /// /// Constructor for PageComponent /// @@ -24,7 +26,7 @@ public PageComponent( ) : base(id, "page", null, children, null, hidden, required, readOnly, extra) { - LayoutId = layoutId; + _layoutId = layoutId; ComponentLookup = componentLookup; } @@ -32,10 +34,11 @@ public PageComponent( public override string PageId => Id; /// - public override string LayoutId { get; } + // ReSharper disable once ConvertToAutoProperty (can't set the virtual auto property in constructor (as per sonar cloud)) + public override string LayoutId => _layoutId; /// - /// Helper dictionary to find components without traversing childern. + /// Helper dictionary to find components without traversing children. /// public Dictionary ComponentLookup { get; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt new file mode 100644 index 000000000..2ee9ccaac --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt @@ -0,0 +1,42 @@ +[ + { + Severity: Error, + DataElementId: Guid_1, + Field: Address, + Code: The field Address must be a string or array type with a minimum length of '44'., + Description: The field Address must be a string or array type with a minimum length of '44'., + Source: DataAnnotations + }, + { + Severity: Error, + DataElementId: Guid_2, + Field: Address, + Code: required, + Description: Address is required in component with id subFormLayout.SubPage.Address for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: Guid_2, + Field: Name, + Code: required, + Description: Name is required in component with id subFormLayout.SubPage.Name for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: Guid_2, + Field: Phone, + Code: required, + Description: Phone is required in component with id subFormLayout.SubPage.Phone for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: Guid_3, + Field: Phone, + Code: The field Phone must match the regular expression '^\+47\d+'., + Description: The field Phone must match the regular expression '^\+47\d+'., + Source: DataAnnotations + } +] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs new file mode 100644 index 000000000..da29559e6 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -0,0 +1,252 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Altinn.App.Common.Tests; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Validation; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; +using Altinn.App.Core.Tests.Features.Validators.Default; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using Xunit.Abstractions; +using DataType = Altinn.Platform.Storage.Interface.Models.DataType; + +namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.SubForm; + +public class SubFormTests : IClassFixture +{ + private record MainFormModel( + [Required] string? Name, + [MinLength(44)] string? Address, + string? Phone, + string? Email = null + ); + + private record SubFormModel( + string? Name, + string? Address, + [RegularExpression(@"^\+47\d+")] string? Phone, + string? Email = null + ); + + private readonly ITestOutputHelper _output; + + private const string Org = "ttd"; + private const string App = "test"; + private const int InstanceOwnerPartyId = 123; + private const string DefaultDataType = "default"; + private const string MainLayoutId = "layout"; + private const string SubformDataType = "subform"; + private const string SubLayoutId = "subFormLayout"; + private const string TaskId = "Task_1"; + + private static readonly string _classRefMain = typeof(MainFormModel).FullName!; + private static readonly string _classRefSub = typeof(SubFormModel).FullName!; + private static readonly Guid _instanceGuid = Guid.Parse("12345678-1234-1234-1234-123456789012"); + private static readonly Guid _mainDataElementGuid = Guid.Parse("12345678-1234-1234-1234-123456789013"); + private static readonly Guid _subFormGuid1 = Guid.Parse("12345678-1234-1234-1234-123456789014"); + private static readonly Guid _subFormGuid2 = Guid.Parse("12345678-1234-1234-1234-123456789015"); + private readonly Instance _instance = + new() + { + AppId = $"{Org}/{App}", + Org = Org, + Id = $"{InstanceOwnerPartyId}/{_instanceGuid}", + InstanceOwner = new InstanceOwner() { PartyId = InstanceOwnerPartyId.ToString() }, + Data = + [ + new DataElement() { Id = $"{_mainDataElementGuid}", DataType = DefaultDataType }, + new DataElement() { Id = $"{_subFormGuid1}", DataType = SubformDataType }, + new DataElement() { Id = $"{_subFormGuid2}", DataType = SubformDataType } + ], + }; + + private static readonly ApplicationMetadata _applicationMetadata = + new($"{Org}/{App}") + { + Org = Org, + Id = $"{Org}/{App}", + DataTypes = + [ + new DataType() + { + Id = DefaultDataType, + TaskId = TaskId, + AppLogic = new ApplicationLogic() { ClassRef = _classRefMain } + }, + new DataType() + { + Id = SubformDataType, + TaskId = TaskId, + AppLogic = new ApplicationLogic() { ClassRef = _classRefSub, AllowInSubform = true } + }, + ] + }; + private readonly IOptions _generalSettings = Options.Create(new GeneralSettings()); + + private static readonly LayoutSetComponent _mainLayoutComponent = + new( + [ + ParsePage( + MainLayoutId, + "MainPage", + $$""" + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "Name", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Name" + }, + "required": true + }, + { + "id": "Address", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Address" + }, + "required": true + }, + { + "id": "Phone", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Phone" + }, + "required": true + }, + { + "id": "SubForm", + "type": "SubForm", + "layoutSetId": "{{SubLayoutId}}" + } + ] + } + } + """ + ), + ], + MainLayoutId, + _applicationMetadata.DataTypes[0] + ); + private static readonly LayoutSetComponent _subLayoutComponent = + new( + [ + ParsePage( + SubLayoutId, + "SubPage", + """ + { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "Name", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Name" + }, + "required": true + }, + { + "id": "Address", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Address" + }, + "required": true + }, + { + "id": "Phone", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Phone" + }, + "required": true + } + ] + }} + """ + ) + ], + SubLayoutId, + _applicationMetadata.DataTypes[1] + ); + + private readonly Mock _appResourcesMock = new(MockBehavior.Strict); + private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly Mock _httpContextAccessorMock = new(MockBehavior.Loose); + + private readonly IServiceCollection _services = new ServiceCollection(); + private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { WriteIndented = true }; + + public SubFormTests(ITestOutputHelper output, DataAnnotationsTestFixture fixture) + { + _output = output; + _services.AddSingleton(_appResourcesMock.Object); + _services.AddSingleton(_appMetadataMock.Object); + _services.AddSingleton(_httpContextAccessorMock.Object); + _services.AddSingleton(fixture.App.Services.GetRequiredService()); + _services.AddSingleton(_generalSettings); + _services.AddTransient(); + _services.AddTransient(); + _services.AddTransient(); + + _services.AddFakeLoggingWithXunit(output); + _appMetadataMock.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(_applicationMetadata); + _appResourcesMock + .Setup(ar => ar.GetLayoutModelForTask(TaskId)) + .Returns(new LayoutModel([_mainLayoutComponent, _subLayoutComponent], null)); + _httpContextAccessorMock.SetupGet(hca => hca.HttpContext).Returns(new DefaultHttpContext()); + } + + [Fact] + public async Task Test1() + { + _services.AddTransient(); + _services.AddTransient(); + using var serviceProvider = _services.BuildServiceProvider(); + + var validationService = serviceProvider.GetRequiredService(); + var dataAccessor = new InstanceDataAccessorFake(_instance) + { + { _instance.Data[0], new MainFormModel("Name", "Address", "Phone") }, + { _instance.Data[1], new SubFormModel(null, null, null) }, + { _instance.Data[2], new SubFormModel("Name2", "Address2", "Phone2") } + }; + + var issues = await validationService.ValidateInstanceAtTask(_instance, TaskId, dataAccessor, null); + _output.WriteLine(JsonSerializer.Serialize(issues, _options)); + + // Order of issues is not guaranteed, so we sort them before verification + await Verify(issues.OrderBy(i => JsonSerializer.Serialize(i))); + } + + private static PageComponent ParsePage(string layoutId, string pageName, [StringSyntax("json")] string json) + { + PageComponentConverter.SetAsyncLocalPageName(layoutId, pageName); + return JsonSerializer.Deserialize(json) ?? throw new JsonException("Deserialization failed"); + } + + ~SubFormTests() + { + _appResourcesMock?.Verify(); + _appMetadataMock?.Verify(); + } +} From ef8dcab47e5a34db609949c03093d2cd76285e52 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sat, 7 Sep 2024 22:42:32 +0200 Subject: [PATCH 31/63] Add appMetadata to LayoutEvaluatorState --- .../Helpers/DataModel/DataModel.cs | 33 +++-- .../Expressions/LayoutEvaluatorState.cs | 3 +- .../LayoutEvaluatorStateInitializer.cs | 29 ++-- .../Models/Expressions/ComponentContext.cs | 8 +- .../Models/Layout/LayoutModel.cs | 126 ++++++++++++------ .../Default/ExpressionValidatorTests.cs | 3 +- .../ExpressionsExclusiveGatewayTests.cs | 12 +- .../Internal/Process/ProcessNavigatorTests.cs | 20 +-- .../CommonTests/ExpressionTestCaseRoot.cs | 1 - .../TestBackendExclusiveFunctions.cs | 3 + .../CommonTests/TestContextList.cs | 5 +- .../CommonTests/TestFunctions.cs | 33 ++++- .../CommonTests/TestInvalid.cs | 4 +- .../FullTests/LayoutTestUtils.cs | 4 +- .../TestUtilities/DynamicClassBuilder.cs | 1 - .../TestUtilities/InstanceDataAccessorFake.cs | 6 +- 16 files changed, 188 insertions(+), 103 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 43f7c04c2..9f6048542 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -12,19 +12,26 @@ namespace Altinn.App.Core.Helpers.DataModel; public class DataModel { private readonly IInstanceDataAccessor _dataAccessor; - private readonly Dictionary _dataIdsByType = []; + private readonly Dictionary _dataIdsByType = []; /// /// Constructor that wraps a POCO data model, and gives extra tool for working with the data /// - public DataModel(IInstanceDataAccessor dataAccessor) + public DataModel(IInstanceDataAccessor dataAccessor, ApplicationMetadata appMetadata) { _dataAccessor = dataAccessor; foreach (var dataElement in dataAccessor.Instance.Data) { - // TODO: only add data elements with maxCount == 1 - // for now only add the first one as this requires reading appMetadata - _dataIdsByType.TryAdd(dataElement.DataType, dataElement); + var dataTypeId = dataElement.DataType; + var dataType = appMetadata.DataTypes.Find(d => d.Id == dataTypeId); + if (dataType is { MaxCount: 1, AppLogic.ClassRef: not null }) + { + _dataIdsByType.TryAdd(dataElement.DataType, dataElement); + } + else + { + _dataIdsByType.TryAdd(dataElement.Id, null); + } } } @@ -50,10 +57,18 @@ DataElementId defaultDataElementId if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) { - return (dataElementId, await _dataAccessor.GetData(dataElementId)); + if (dataElementId is null) + { + throw new InvalidOperationException( + $"{key.DataType} has maxCount different from 1 in applicationmetadata.json or don't have a classRef in appLogic" + ); + } + return (dataElementId.Value, await _dataAccessor.GetData(dataElementId.Value)); } - throw new InvalidOperationException("Data model with type " + key.DataType + " not found"); + throw new InvalidOperationException( + $"Data model with type {key.DataType} not found in applicationmetadata.json" + ); } /// @@ -128,7 +143,7 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa var (dataElementId, serviceModel) = await ServiceModelAndDataElementId(key, defaultDataElementId); if (serviceModel is null) { - throw new DataModelException("Could not find service model for dataType " + key.DataType); + throw new DataModelException($"Could not find service model for dataType {key.DataType}"); } var modelWrapper = new DataModelWrapper(serviceModel); @@ -148,7 +163,7 @@ RowRemovalOption rowRemovalOption var serviceModel = await ServiceModel(key, defaultDataElementId); if (serviceModel is null) { - throw new DataModelException("Could not find service model for dataType " + key.DataType); + throw new DataModelException($"Could not find service model for dataType {key.DataType}"); } var modelWrapper = new DataModelWrapper(serviceModel); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 74204d2c8..d1b979450 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -29,11 +29,12 @@ public LayoutEvaluatorState( IInstanceDataAccessor dataAccessor, LayoutModel? componentModel, FrontEndSettings frontEndSettings, + ApplicationMetadata applicationMetadata, string? gatewayAction = null, string? language = null ) { - _dataModel = new DataModel(dataAccessor); + _dataModel = new DataModel(dataAccessor, applicationMetadata); _componentModel = componentModel; _frontEndSettings = frontEndSettings; _instanceContext = dataAccessor.Instance; diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index c7639afe8..51186ed56 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -17,14 +17,20 @@ public class LayoutEvaluatorStateInitializer : ILayoutEvaluatorStateInitializer { // Dependency injection properties (set in ctor) private readonly IAppResources _appResources; + private readonly IAppMetadata _appMetadata; private readonly FrontEndSettings _frontEndSettings; /// /// Constructor with services from dependency injection /// - public LayoutEvaluatorStateInitializer(IAppResources appResources, IOptions frontEndSettings) + public LayoutEvaluatorStateInitializer( + IAppResources appResources, + IAppMetadata appMetadata, + IOptions frontEndSettings + ) { _appResources = appResources; + _appMetadata = appMetadata; _frontEndSettings = frontEndSettings.Value; } @@ -76,7 +82,7 @@ public Task GetData(DataElementId dataElementId) /// Initialize LayoutEvaluatorState with given Instance, data object and layoutSetId /// [Obsolete("Use the overload with ILayoutEvaluatorStateInitializer instead")] - public Task Init( + public async Task Init( Instance instance, object data, string? layoutSetId, @@ -86,29 +92,22 @@ public Task Init( var layouts = _appResources.GetLayoutModel(layoutSetId); var dataElement = instance.Data.Find(d => d.DataType == layouts.DefaultDataType.Id); Debug.Assert(dataElement is not null); + var appMetadata = await _appMetadata.GetApplicationMetadata(); var dataAccessor = new SingleDataElementAccessor(instance, dataElement, data); - return Task.FromResult(new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, gatewayAction)); + return new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, appMetadata, gatewayAction); } /// - public Task Init( + public async Task Init( IInstanceDataAccessor dataAccessor, string? taskId, string? gatewayAction = null, string? language = null ) { - try - { - LayoutModel? layouts = taskId is not null ? _appResources.GetLayoutModelForTask(taskId) : null; + LayoutModel? layouts = taskId is not null ? _appResources.GetLayoutModelForTask(taskId) : null; + var appMetadata = await _appMetadata.GetApplicationMetadata(); - return Task.FromResult( - new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, gatewayAction, language) - ); - } - catch (Exception e) - { - return Task.FromException(e); - } + return new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, appMetadata, gatewayAction, language); } } diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index fe67508f7..05bf6133b 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -72,6 +72,10 @@ public async Task IsHidden(LayoutEvaluatorState state) /// public async Task GetHiddenRows(LayoutEvaluatorState state) { + if (_hiddenRows is not null) + { + return _hiddenRows; + } if (Component is not RepeatingGroupComponent) { throw new InvalidOperationException("HiddenRows can only be called on a repeating group"); @@ -80,10 +84,6 @@ public async Task GetHiddenRows(LayoutEvaluatorState state) { throw new InvalidOperationException("RowLength must be set to call HiddenRows on repeating group"); } - if (_hiddenRows is not null) - { - return _hiddenRows; - } var hiddenRows = new BitArray(_rowLength.Value); foreach (var index in Enumerable.Range(0, hiddenRows.Length)) diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index 73dcd7510..af584e28f 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -10,7 +10,6 @@ namespace Altinn.App.Core.Models.Layout; /// public class LayoutModel { - private readonly List _layouts; private readonly Dictionary _layoutsLookup; private readonly LayoutSetComponent _defaultLayoutSet; @@ -21,7 +20,6 @@ public class LayoutModel /// Optional default layout (if not just using the first) public LayoutModel(List layouts, LayoutSet? defaultLayout) { - _layouts = layouts; _layoutsLookup = layouts.ToDictionary(l => l.Id); _defaultLayoutSet = defaultLayout is not null ? _layoutsLookup[defaultLayout.Id] : layouts[0]; } @@ -94,64 +92,106 @@ private async Task GenerateComponentContextsRecurs( int[]? indexes ) { - var children = new List(); - int? rowLength = null; - - if (component is SubFormComponent subFormComponent) + return component switch { - var layoutSetId = subFormComponent.LayoutSetId; - var layout = _layoutsLookup[layoutSetId]; - var dataElementsForSubForm = dataModel.Instance.Data.Where(d => d.DataType == layout.DefaultDataType.Id); - foreach (var dataElement in dataElementsForSubForm) - { - List subforms = new(); + SubFormComponent subFormComponent + => await GenerateContextForSubComponent(dataModel, subFormComponent, defaultDataElementId), - foreach (var page in layout.Pages) - { - subforms.Add(await GenerateComponentContextsRecurs(page, dataModel, dataElement, indexes: null)); - } + RepeatingGroupComponent repeatingGroupComponent + => await GenerateContextForRepeatingGroup( + dataModel, + repeatingGroupComponent, + defaultDataElementId, + indexes + ), + GroupComponent groupComponent + => await GenerateContextForGroup(dataModel, groupComponent, defaultDataElementId, indexes), + _ => new ComponentContext(component, indexes?.Length > 0 ? indexes : null, null, defaultDataElementId, []) + }; + } - children.Add(new ComponentContext(subFormComponent, null, null, dataElement, subforms)); - } + private async Task GenerateContextForGroup( + DataModel dataModel, + GroupComponent groupComponent, + DataElementId defaultDataElementId, + int[]? indexes + ) + { + List children = []; + foreach (var child in groupComponent.Children) + { + children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); } - else if (component is RepeatingGroupComponent repeatingGroupComponent) + + return new ComponentContext( + groupComponent, + indexes?.Length > 0 ? indexes : null, + null, + defaultDataElementId, + children + ); + } + + private async Task GenerateContextForRepeatingGroup( + DataModel dataModel, + RepeatingGroupComponent repeatingGroupComponent, + DataElementId defaultDataElementId, + int[]? indexes + ) + { + int? rowLength = null; + var children = new List(); + if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) { - if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) + rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; + foreach (var index in Enumerable.Range(0, rowLength.Value)) { - rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; - foreach (var index in Enumerable.Range(0, rowLength.Value)) + foreach (var child in repeatingGroupComponent.Children) { - foreach (var child in repeatingGroupComponent.Children) - { - // concatenate [...indexes, index] - var subIndexes = new int[(indexes?.Length ?? 0) + 1]; - indexes?.CopyTo(subIndexes.AsSpan()); - subIndexes[^1] = index; - - children.Add( - await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) - ); - } + // concatenate [...indexes, index] + var subIndexes = new int[(indexes?.Length ?? 0) + 1]; + indexes?.CopyTo(subIndexes.AsSpan()); + subIndexes[^1] = index; + + children.Add( + await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) + ); } } } - else if (component is GroupComponent groupComponent) - { - foreach (var child in groupComponent.Children) - { - children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); - } - } - var context = new ComponentContext( - component, + return new ComponentContext( + repeatingGroupComponent, indexes?.Length > 0 ? indexes : null, rowLength, defaultDataElementId, children ); + } + + private async Task GenerateContextForSubComponent( + DataModel dataModel, + SubFormComponent subFormComponent, + DataElementId defaultDataElementId + ) + { + List children = []; + var layoutSetId = subFormComponent.LayoutSetId; + var layout = _layoutsLookup[layoutSetId]; + var dataElementsForSubForm = dataModel.Instance.Data.Where(d => d.DataType == layout.DefaultDataType.Id); + foreach (var dataElement in dataElementsForSubForm) + { + List subForms = []; + + foreach (var page in layout.Pages) + { + subForms.Add(await GenerateComponentContextsRecurs(page, dataModel, dataElement, indexes: null)); + } + + children.Add(new ComponentContext(subFormComponent, null, null, dataElement, subForms)); + } - return context; + return new ComponentContext(subFormComponent, null, null, defaultDataElementId, children); } internal DataElementId GetDefaultDataElementId(Instance instanceContext) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 2e01b14d8..0851ce5a8 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -73,12 +73,13 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var instance = new Instance() { Id = "1337/fa0678ad-960d-4307-aba2-ba29c9804c9d", AppId = "org/app", }; var dataElement = new DataElement { DataType = "default", }; var dataType = new DataType() { Id = "default" }; + var appMedatada = new ApplicationMetadata("org/app") { DataTypes = [dataType], }; var dataModel = DynamicClassBuilder.DataAccessorFromJsonDocument(instance, testCase.FormData, dataElement); var layout = new LayoutSetComponent(testCase.Layouts.Values.ToList(), "layout", dataType); var componentModel = new LayoutModel([layout], null); - var evaluatorState = new LayoutEvaluatorState(dataModel, componentModel, _frontendSettings.Value); + var evaluatorState = new LayoutEvaluatorState(dataModel, componentModel, _frontendSettings.Value, appMedatada); _layoutInitializer .Setup(init => init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 7dc7a67f2..25e36dbab 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -249,10 +249,8 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew ) { _resources.Setup(r => r.GetLayoutSetForTask("Task_1")).Returns(layoutSet); - _appMetadata - .Setup(m => m.GetApplicationMetadata()) - .ReturnsAsync(new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }) - .Verifiable(Times.Once); + var appMetadata = new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }; + _appMetadata.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(appMetadata).Verifiable(Times.AtLeastOnce); if (formData != null) { _dataClient @@ -280,7 +278,11 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew _appModel.Object ); - var layoutStateInit = new LayoutEvaluatorStateInitializer(_resources.Object, frontendSettings); + var layoutStateInit = new LayoutEvaluatorStateInitializer( + _resources.Object, + _appMetadata.Object, + frontendSettings + ); return (new ExpressionsExclusiveGateway(layoutStateInit, _resources.Object), dataAccessor); } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index f33a0eeb4..5fde6cdf5 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -24,7 +24,7 @@ public class ProcessNavigatorTests [Fact] public async Task GetNextTask_returns_next_element_if_no_gateway() { - IProcessNavigator processNavigator = SetupProcessNavigator("simple-linear.bpmn", []); + var processNavigator = SetupProcessNavigator("simple-linear.bpmn", []); ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); nextElements .Should() @@ -47,7 +47,7 @@ public async Task GetNextTask_returns_next_element_if_no_gateway() public async Task GetNextTask_single_sign_to_process_end() { var processFile = "with-double-sign.bpmn"; - IProcessNavigator processNavigator = SetupProcessNavigator(processFile, [new SingleSignGateway()]); + var processNavigator = SetupProcessNavigator(processFile, [new SingleSignGateway()]); var instance = new Instance() { Id = $"123/{Guid.NewGuid()}", AppId = "org/app", }; ProcessElement? nextElements = await processNavigator.GetNextTask(instance, "Task_Sign1", "sign"); nextElements!.Id.Should().Be("EndEvent_1"); @@ -74,7 +74,7 @@ ProcessGatewayInformation processGatewayInformation public async Task GetNextTask_exclusive_gateway_zero_paths_should_fail() { var processFile = "with-double-sign.bpmn"; - IProcessNavigator processNavigator = SetupProcessNavigator(processFile, [new ZeroPathsGateway()]); + var processNavigator = SetupProcessNavigator(processFile, [new ZeroPathsGateway()]); var instance = new Instance() { Id = $"123/{Guid.NewGuid()}", AppId = "org/app", }; await Assert.ThrowsAsync( @@ -100,7 +100,7 @@ ProcessGatewayInformation processGatewayInformation [Fact] public async Task NextFollowAndFilterGateways_returns_empty_list_if_no_outgoing_flows() { - IProcessNavigator processNavigator = SetupProcessNavigator("simple-linear.bpmn", []); + var processNavigator = SetupProcessNavigator("simple-linear.bpmn", []); ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "EndEvent", null); nextElements.Should().BeNull(); } @@ -108,7 +108,7 @@ public async Task NextFollowAndFilterGateways_returns_empty_list_if_no_outgoing_ [Fact] public async Task GetNextTask_returns_default_if_no_filtering_is_implemented_and_default_set() { - IProcessNavigator processNavigator = SetupProcessNavigator("simple-gateway-default.bpmn", []); + var processNavigator = SetupProcessNavigator("simple-gateway-default.bpmn", []); ProcessElement? nextElements = await processNavigator.GetNextTask(new Instance(), "Task1", null); nextElements .Should() @@ -134,7 +134,7 @@ public async Task GetNextTask_returns_default_if_no_filtering_is_implemented_and [Fact] public async Task GetNextTask_runs_custom_filter_and_returns_result() { - IProcessNavigator processNavigator = SetupProcessNavigator( + var processNavigator = SetupProcessNavigator( "simple-gateway-with-join-gateway.bpmn", [new DataValuesFilter("Gateway1", "choose")] ); @@ -170,7 +170,7 @@ [new DataValuesFilter("Gateway1", "choose")] [Fact] public async Task GetNextTask_throws_ProcessException_if_multiple_targets_found() { - IProcessNavigator processNavigator = SetupProcessNavigator( + var processNavigator = SetupProcessNavigator( "simple-gateway-with-join-gateway.bpmn", new List() { new DataValuesFilter("Foobar", "choose") } ); @@ -187,7 +187,7 @@ public async Task GetNextTask_throws_ProcessException_if_multiple_targets_found( [Fact] public async Task GetNextTask_follows_downstream_gateways() { - IProcessNavigator processNavigator = SetupProcessNavigator( + var processNavigator = SetupProcessNavigator( "simple-gateway-with-join-gateway.bpmn", new List() { new DataValuesFilter("Gateway1", "choose1") } ); @@ -214,7 +214,7 @@ public async Task GetNextTask_follows_downstream_gateways() [Fact] public async Task GetNextTask_runs_custom_filter_and_throws_exception_when_no_paths_are_found() { - IProcessNavigator processNavigator = SetupProcessNavigator( + var processNavigator = SetupProcessNavigator( "simple-gateway-with-join-gateway.bpmn", new List() { @@ -235,7 +235,7 @@ public async Task GetNextTask_runs_custom_filter_and_throws_exception_when_no_pa [Fact] public async Task GetNextTask_returns_empty_list_if_element_has_no_next() { - IProcessNavigator processNavigator = SetupProcessNavigator( + var processNavigator = SetupProcessNavigator( "simple-gateway-with-join-gateway.bpmn", new List() ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index e9142019f..97b36bc93 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -2,7 +2,6 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index 092786acd..4b5b83caf 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -61,6 +62,7 @@ private async Task RunTestCase(string testName, string folder) _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); var dataType = new DataType() { Id = "default", }; + var appMetadata = new ApplicationMetadata("org/app") { DataTypes = [dataType], }; var layout = new LayoutSetComponent(test.Layouts!.Values.ToList(), "layout", dataType); var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( @@ -70,6 +72,7 @@ private async Task RunTestCase(string testName, string folder) ), componentModel, test.FrontEndSettings ?? new(), + appMetadata, test.GatewayAction, test.ProfileSettings?.Language ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs index b3bd860a6..77331efa9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -67,6 +68,7 @@ private async Task RunTestCase(string filename, string folder) var instance = new Instance() { Data = [] }; var dataType = new DataType() { Id = "default" }; + var appMetadata = new ApplicationMetadata("org/app") { DataTypes = [dataType], }; var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( @@ -75,7 +77,8 @@ private async Task RunTestCase(string filename, string folder) test.DataModel ?? JsonDocument.Parse("{}").RootElement ), componentModel, - new() + new(), + appMetadata ); test.ParsingException.Should().BeNull("Loading of test failed"); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 8b784743a..2e13925d5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -164,25 +166,44 @@ private async Task RunTestCase(string testName, string folder) _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); - var dataAccessor = test.DataModels is null - ? DynamicClassBuilder.DataAccessorFromJsonDocument( + IInstanceDataAccessor dataAccessor; + List dataTypes = new(); + if (test.DataModels is null) + { + dataTypes.Add(new DataType() { Id = "default" }); + dataAccessor = DynamicClassBuilder.DataAccessorFromJsonDocument( test.Instance, test.DataModel ?? JsonDocument.Parse("{}").RootElement - ) - : DynamicClassBuilder.DataAccessorFromJsonDocument(test.Instance, test.DataModels); + ); + } + else + { + dataTypes.AddRange( + test.DataModels.Select(d => d.DataElement.DataType) + .Distinct() + .Select(dt => new DataType() + { + Id = dt, + MaxCount = 1, + AppLogic = new() { ClassRef = "not-in-user" } + }) + ); + dataAccessor = DynamicClassBuilder.DataAccessorFromJsonDocument(test.Instance, test.DataModels); + } - var dataType = new DataType() { Id = "default" }; + var appMedatada = new ApplicationMetadata("org/app") { DataTypes = dataTypes, }; LayoutModel? componentModel = null; if (test.Layouts is not null) { - var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataTypes[0]); componentModel = new LayoutModel([layout], null); } var state = new LayoutEvaluatorState( dataAccessor, componentModel, test.FrontEndSettings ?? new FrontEndSettings(), + appMedatada, test.GatewayAction, test.ProfileSettings?.Language ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index a816981e9..9ff1c88c8 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -46,7 +47,8 @@ public async Task Simple_Theory(string testName, string folder) test.DataModel ?? JsonDocument.Parse("{}").RootElement ), componentModel, - test.FrontEndSettings ?? new() + test.FrontEndSettings ?? new(), + new ApplicationMetadata("org/app") { DataTypes = [dataType], } ); await ExpressionEvaluator.EvaluateExpression( state, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index c3ae025a0..a1e9887c3 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -3,14 +3,12 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; -using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -90,7 +88,7 @@ public static async Task GetLayoutModelTools(object model, resources.Setup(r => r.GetLayoutModelForTask(TaskId)).Returns(layoutModel); services.AddSingleton(resources.Object); - // services.AddSingleton(appMetadata.Object); + services.AddSingleton(appMetadata.Object); // services.AddSingleton(appModel.Object); services.AddScoped(); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs index 3e60bf10b..964c0d517 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/DynamicClassBuilder.cs @@ -3,7 +3,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Features; -using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; using Altinn.Platform.Storage.Interface.Models; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index cb99a3e0a..9bce9145f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -18,6 +18,7 @@ public InstanceDataAccessorFake(Instance instance, ApplicationMetadata? applicat private readonly Dictionary _dataById = new(); private readonly Dictionary _dataByType = new(); + private readonly List> _data = new(); public void Add(DataElement? dataElement, object data) { @@ -52,6 +53,7 @@ public void Add(DataElement? dataElement, object data) // We don't have application metadata, so just add the first element we see _dataByType.TryAdd(dataElement.DataType, data); } + _data.Add(KeyValuePair.Create(dataElement, data)); } public Instance Instance { get; } @@ -68,8 +70,8 @@ public Task GetData(DataElementId dataElementId) public IEnumerator> GetEnumerator() { - // We implement IEnumerable so that we can use the collection initializer syntax - throw new NotImplementedException(); + // We implement IEnumerable so that we can use the collection initializer syntax, but we also store the elements for debugger visualization + return _data.GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() From 110b8e8262bdc4530e2f3f72ac2b6a06ddb4c0b5 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sat, 7 Sep 2024 23:19:39 +0200 Subject: [PATCH 32/63] Fix typos and make sonar more happy --- .../Controllers/DataController.cs | 20 ++-- .../Validation/Default/ExpressionValidator.cs | 103 ++++++++++-------- 2 files changed, 69 insertions(+), 54 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 1332d1ec8..84f78a3a9 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -257,10 +257,10 @@ [FromQuery] string dataType /// /// File validation requires json object in response and is introduced in the - /// the methods above validating files. In order to be consistent for the return types + /// methods above validating files. In order to be consistent for the return types /// of this controller, old methods are updated to return json object in response. /// Since this is a breaking change, a feature flag is introduced to control the behaviour, - /// and the developer need to opt-in to the new behaviour. Json object are by default + /// and the developer need to opt in to the new behaviour. Json object are by default /// returned as part of file validation which is a new feature. /// private async Task GetErrorDetails(List errors) @@ -325,7 +325,7 @@ public async Task Get( if (dataType is null) { var error = - $"Could not determine if {dataElement?.DataType} requires app logic for application {org}/{app}"; + $"Could not determine if {dataElement.DataType} requires app logic for application {org}/{app}"; _logger.LogError(error); return BadRequest(error); } @@ -360,7 +360,7 @@ public async Task Get( /// /// Updates an existing data element with new content. /// - /// unique identfier of the organisation responsible for the app + /// unique identifier of the organisation responsible for the app /// application identifier which is unique within an organisation /// unique id of the party that is the owner of the instance /// unique id to identify the instance @@ -442,7 +442,7 @@ public async Task Put( /// /// Updates an existing form data element with a patch of changes. /// - /// unique identfier of the organisation responsible for the app + /// unique identifier of the organisation responsible for the app /// application identifier which is unique within an organisation /// unique id of the party that is the owner of the instance /// unique id to identify the instance @@ -489,9 +489,9 @@ public async Task> PatchFormData( } /// - /// Updates an existing form data element with patches to mulitple data elements. + /// Updates an existing form data element with patches to multiple data elements. /// - /// unique identfier of the organisation responsible for the app + /// unique identifier of the organisation responsible for the app /// application identifier which is unique within an organisation /// unique id of the party that is the owner of the instance /// unique id to identify the instance @@ -608,7 +608,7 @@ await UpdatePresentationTextsOnInstance( /// /// Delete a data element. /// - /// unique identfier of the organisation responsible for the app + /// unique identifier of the organisation responsible for the app /// application identifier which is unique within an organisation /// unique id of the party that is the owner of the instance /// unique id to identify the instance @@ -836,12 +836,12 @@ Guid dataGuid private async Task GetDataType(DataElement element) { Application application = await _appMetadata.GetApplicationMetadata(); - return application?.DataTypes.Find(e => e.Id == element.DataType); + return application.DataTypes.Find(e => e.Id == element.DataType); } /// /// Gets a data element (form data) from storage and performs business logic on it (e.g. to calculate certain fields) before it is returned. - /// If more there are more data elements of the same dataType only the first one is returned. In that case use the more spesific + /// If more there are more data elements of the same dataType only the first one is returned. In that case use the more specific /// GET method to fetch a particular data element. /// /// data element is returned in response body diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 6cb22abee..db987cc74 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -137,50 +137,14 @@ internal async Task> ValidateFormData( var positionalArguments = new object[] { resolvedField }; foreach (var validation in validations) { - try - { - if (validation.Condition == null) - { - continue; - } - - var validationResult = await ExpressionEvaluator.EvaluateExpression( - evaluatorState, - validation.Condition.Value, - context, - positionalArguments - ); - switch (validationResult) - { - case true: - var validationIssue = new ValidationIssue - { - Field = resolvedField.Field, - DataElementId = resolvedField.DataElementId.Id.ToString(), - Severity = validation.Severity ?? ValidationIssueSeverity.Error, - CustomTextKey = validation.Message, - Code = validation.Message, - }; - validationIssues.Add(validationIssue); - - break; - case false: - break; - default: - throw new ArgumentException( - $"Validation condition for {resolvedField} did not evaluate to a boolean" - ); - } - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while evaluating expression validation for {resolvedField}", - resolvedField - ); - throw; - } + await RunValidation( + evaluatorState, + validationIssues, + resolvedField, + context, + positionalArguments, + validation + ); } } } @@ -188,6 +152,57 @@ internal async Task> ValidateFormData( return validationIssues; } + private async Task RunValidation( + LayoutEvaluatorState evaluatorState, + List validationIssues, + DataReference resolvedField, + ComponentContext context, + object[] positionalArguments, + ExpressionValidation validation + ) + { + try + { + if (validation.Condition == null) + { + return; + } + + var validationResult = await ExpressionEvaluator.EvaluateExpression( + evaluatorState, + validation.Condition.Value, + context, + positionalArguments + ); + switch (validationResult) + { + case true: + var validationIssue = new ValidationIssue + { + Field = resolvedField.Field, + DataElementId = resolvedField.DataElementId.Id.ToString(), + Severity = validation.Severity ?? ValidationIssueSeverity.Error, + CustomTextKey = validation.Message, + Code = validation.Message, + }; + validationIssues.Add(validationIssue); + + break; + case false: + break; + default: + throw new ArgumentException( + $"Validation condition for {resolvedField} did not evaluate to a boolean" + ); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error while evaluating expression validation for {resolvedField}", resolvedField); + throw; + } + } + private static RawExpressionValidation? ResolveValidationDefinition( string name, JsonElement definition, From dfb250aa2acc94f9a3e5bda1a92eb748ed9236c0 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sun, 8 Sep 2024 13:42:25 +0200 Subject: [PATCH 33/63] Revert changes to use scoped services Opened #751 to consider making bigger changes for v9 --- .../Extensions/ServiceCollectionExtensions.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 43a98f0a1..cbe1bf929 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -172,7 +172,7 @@ IWebHostEnvironment env services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); - services.TryAddScoped(); + services.TryAddTransient(); services.AddTransient(); services.Configure(configuration.GetSection("PEPSettings")); services.Configure(configuration.GetSection("PlatformSettings")); @@ -206,7 +206,7 @@ IWebHostEnvironment env private static void AddValidationServices(IServiceCollection services, IConfiguration configuration) { services.AddTransient(); - services.AddScoped(); + services.AddTransient(); if (configuration.GetSection("AppSettings").Get()?.RequiredValidation == true) { services.AddTransient(); @@ -321,7 +321,7 @@ private static void AddExternalApis(IServiceCollection services) private static void AddProcessServices(IServiceCollection services) { - services.TryAddScoped(); + services.TryAddTransient(); services.TryAddTransient(); services.TryAddSingleton(); services.TryAddTransient(); From f71dad169026a042825922842ae6ff6aea259e78 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sun, 8 Sep 2024 15:11:33 +0200 Subject: [PATCH 34/63] Remove unused usings --- .../Internal/Expressions/LayoutEvaluatorStateInitializer.cs | 1 - src/Altinn.App.Core/Internal/Patch/IPatchService.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 51186ed56..ab8080388 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; -using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; diff --git a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs index 2e860b7c6..a92e70a98 100644 --- a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using Altinn.App.Core.Models.Result; using Altinn.Platform.Storage.Interface.Models; using Json.Patch; From 4c6f4d02791c017e1d10ee2e3b387e39e7f17670 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sun, 8 Sep 2024 21:04:23 +0200 Subject: [PATCH 35/63] Remove implicit cast, as it was no longer used in tests (and hid bugs in main code) --- .../Helpers/DataModel/DataModel.cs | 16 +++++++--------- .../Internal/Expressions/ExpressionEvaluator.cs | 7 ++++++- .../Internal/Expressions/LayoutEvaluator.cs | 2 +- .../Internal/Expressions/LayoutEvaluatorState.cs | 4 ++-- .../Models/Layout/ModelBinding.cs | 9 --------- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index 9f6048542..d308f7d04 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -109,7 +109,7 @@ DataElementId defaultDataElementId /// public async Task GetResolvedKeys(DataReference reference) { - var model = await ServiceModel(reference.Field, reference.DataElementId); + var model = await _dataAccessor.GetData(reference.DataElementId); var modelWrapper = new DataModelWrapper(model); return modelWrapper .GetResolvedKeys(reference.Field) @@ -154,20 +154,18 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa /// /// Set the value of a field in the model to default (null) /// - public async Task RemoveField( - ModelBinding key, - DataElementId defaultDataElementId, - RowRemovalOption rowRemovalOption - ) + public async Task RemoveField(DataReference reference, RowRemovalOption rowRemovalOption) { - var serviceModel = await ServiceModel(key, defaultDataElementId); + var serviceModel = await _dataAccessor.GetData(reference.DataElementId); if (serviceModel is null) { - throw new DataModelException($"Could not find service model for dataType {key.DataType}"); + throw new DataModelException( + $"Could not find service model for data element id {reference.DataElementId} to remove values" + ); } var modelWrapper = new DataModelWrapper(serviceModel); - modelWrapper.RemoveField(key.Field, rowRemovalOption); + modelWrapper.RemoveField(reference.Field, rowRemovalOption); } // /// diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index 102c37874..c223c6cdc 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -109,7 +109,12 @@ bool defaultReturn { if (args is [DataReference dataReference]) { - return await DataModel(dataReference.Field, dataReference.DataElementId, context.RowIndices, state); + return await DataModel( + new ModelBinding() { Field = dataReference.Field }, + dataReference.DataElementId, + context.RowIndices, + state + ); } var key = args switch { diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index fb1a6e6f6..c84ef4291 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -127,7 +127,7 @@ public static async Task RemoveHiddenData(LayoutEvaluatorState state, RowRemoval var fields = await GetHiddenFieldsForRemoval(state); foreach (var dataReference in fields) { - await state.RemoveDataField(dataReference.Field, dataReference.DataElementId, rowRemovalOption); + await state.RemoveDataField(dataReference, rowRemovalOption); } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index d1b979450..b02599e7a 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -156,9 +156,9 @@ public async Task GetResolvedKeys(DataReference reference) /// /// Set the value of a field to null. /// - public async Task RemoveDataField(ModelBinding key, DataElementId dataElementId, RowRemovalOption rowRemovalOption) + public async Task RemoveDataField(DataReference key, RowRemovalOption rowRemovalOption) { - await _dataModel.RemoveField(key, dataElementId, rowRemovalOption); + await _dataModel.RemoveField(key, rowRemovalOption); } /// diff --git a/src/Altinn.App.Core/Models/Layout/ModelBinding.cs b/src/Altinn.App.Core/Models/Layout/ModelBinding.cs index 14ad613cd..bb2e10010 100644 --- a/src/Altinn.App.Core/Models/Layout/ModelBinding.cs +++ b/src/Altinn.App.Core/Models/Layout/ModelBinding.cs @@ -18,13 +18,4 @@ public readonly record struct ModelBinding /// [JsonPropertyName("dataType")] public string? DataType { get; init; } - - /// - /// Implicit conversion from string to for - /// backwards convenience - /// - public static implicit operator ModelBinding(string field) - { - return new ModelBinding { Field = field, }; - } } From 225f9346eb6a93e05f8eb9a4466393bdac99490a Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 9 Sep 2024 08:26:01 +0200 Subject: [PATCH 36/63] Remove file added by error --- test/Suggestions for DI scope issue.md | 28 -------------------------- 1 file changed, 28 deletions(-) delete mode 100644 test/Suggestions for DI scope issue.md diff --git a/test/Suggestions for DI scope issue.md b/test/Suggestions for DI scope issue.md deleted file mode 100644 index 7b7fb393f..000000000 --- a/test/Suggestions for DI scope issue.md +++ /dev/null @@ -1,28 +0,0 @@ -## Issue description -Service owner had an implementation of `IInstantiationProcessor` that was registered as a Transient service, and used a class variable to store the user object from IHttpContextAccessor. -This would be fine if our code actually resolved the implementation in a transient context. -An error in our code caused IInstanceProcessor to be resolved as a dependency from a Singleton service, and thus only one instance was ever created. -This caused the class variable to be shared between all requests, and thus the user object to be shared between all requests, leaking the information from the first user that created an instance to all subsequent forms until the app was restarted in kubernetes. - -## Immediate actions - -### Go through all apps to see if any appears to have a similar issue with the . -See if we need to alert other service owners (not sure if Martin regards this as complete) - -### Go through all Singleton services to ensure that they can really be singleton -This would have detected our error, and might find other similar issues (but is a one time job, not fixing things for the future) - -## Future actions to reduce the risk of similar issues. - -### Use IServiceProvider instead of injecting hook interfaces directly where they are used -This will have multiple benefits in addition to make Transient services (actually transient) when used from a Singleton service -* One class that wraps `IServiceProvider` for all our "officially supported" hook interfaces makes it obvious (in code) which interfaces are regarded as extension points and makes it easier to ensure consistent patterns and telemetry. -* Constructors for service implementations (that might be slow for /wrong/ code) will only run when actually required (not all calls to other endpoints on controller) -* We will have the ability to create a telemetry span for the `services.GetServices` call, highlighting slow constructors. - -### Ensure(Verify) that apps by default runs with verifyScopes -Scoped services might otherwise inherit the root (singleton) scope, so this validation might help us catch issues. - -### (Probably not) Suggest in documentation that hook interfaces gets registered with "AddScoped" -Will ensure that a similar issue will gets detected if the app DI runs with verifyScopes. -But if verifyScopes is off, this will cause cross request reuse if the service is resolved in a singleton context. From cc68b176563084f88472817103ec2d68b8d94fb9 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 08:09:23 +0200 Subject: [PATCH 37/63] Add ignoredValidators as a comma separated GET parameter to /validate endpoint --- .../Controllers/ProcessController.cs | 1 + .../Controllers/ValidateController.cs | 12 +++- .../Features/Telemetry.InterfaceFactory.cs | 11 ---- .../Telemetry/Telemetry.Validation.cs | 7 ++- .../Features/Telemetry/Telemetry.cs | 1 + .../Internal/Validation/IValidationService.cs | 8 +-- .../Internal/Validation/ValidationService.cs | 49 ++++++++-------- .../Controllers/ValidateControllerTests.cs | 6 +- .../ValidateControllerValidateDataTests.cs | 1 + .../Altinn.App.Api.Tests/OpenApi/swagger.json | 7 +++ .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 4 ++ .../Validators/ValidationServiceOldTests.cs | 20 ++++++- .../Validators/ValidationServiceTests.cs | 3 + .../SubForm/SubFormTests.Test1.verified.txt | 58 +++++++++++++++++-- .../FullTests/SubForm/SubFormTests.cs | 56 ++++++++++++++---- .../FullTests/Test1/RunTest1.cs | 1 + .../FullTests/Test2/RunTest2.cs | 1 + .../FullTests/Test3/RunTest3.cs | 1 + 18 files changed, 184 insertions(+), 63 deletions(-) delete mode 100644 src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index f8b4b537a..9d792d963 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -254,6 +254,7 @@ [FromRoute] Guid instanceGuid instance, currentTaskId, dataAcceesor, + ignoredValidators: null, // run full validation language ); var success = validationIssues.TrueForAll(v => v.Severity != ValidationIssueSeverity.Error); diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index 0238f6293..ef44d6015 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -50,6 +50,7 @@ IAppModel appModel /// Application identifier which is unique within an organisation /// Unique id of the party that is the owner of the instance. /// Unique id to identify the instance + /// Comma separated list of validators to ignore /// The currently used language by the user (or null if not available) [HttpGet] [Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/validate")] @@ -59,6 +60,7 @@ public async Task ValidateInstance( [FromRoute] string app, [FromRoute] int instanceOwnerPartyId, [FromRoute] Guid instanceGuid, + [FromQuery] string? ignoredValidators = null, [FromQuery] string? language = null ) { @@ -77,10 +79,12 @@ public async Task ValidateInstance( try { var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var ignoredSources = ignoredValidators?.Split(',').ToList(); List messages = await _validationService.ValidateInstanceAtTask( instance, taskId, dataAccessor, + ignoredSources, language ); return Ok(messages); @@ -151,7 +155,13 @@ public async Task ValidateData( var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); // Run validations for all data elements, but only return the issues for the specific data element - var issues = await _validationService.ValidateInstanceAtTask(instance, dataType.TaskId, dataAccessor, language); + var issues = await _validationService.ValidateInstanceAtTask( + instance, + dataType.TaskId, + dataAccessor, + ignoredValidators: null, + language + ); messages.AddRange(issues.Where(i => i.DataElementId == element.Id)); string taskId = instance.Process.CurrentTask.ElementId; diff --git a/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs b/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs deleted file mode 100644 index cf79b41c8..000000000 --- a/src/Altinn.App.Core/Features/Telemetry.InterfaceFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Diagnostics; - -namespace Altinn.App.Core.Features; - -public partial class Telemetry -{ - internal Activity? GetUserDefinedService(string name) - { - return ActivitySource.StartActivity($"GetUserDefinedService{name}"); - } -} diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs index b4ac4eb84..ace36672f 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs @@ -30,7 +30,12 @@ List changes var activity = ActivitySource.StartActivity($"{Prefix}.ValidateIncremental"); activity?.SetTaskId(taskId); activity?.SetInstanceId(instance); - // TODO: record the guid for the changed elements in a sensible list + // Log the IDs of the elements that have changed together with their data type + // default:123-678-8900-54,group:123-678-8900-55 + activity?.SetTag( + InternalLabels.ValidatorChangedElementsIds, + string.Join(',', changes.Select(c => $"{c.DataElement.DataType}:{c.DataElement.Id}")) + ); return activity; } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs index abdc5b24c..0ad61eae2 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs @@ -181,6 +181,7 @@ internal static class InternalLabels internal const string ValidatorType = "validator.type"; internal const string ValidatorSource = "validator.source"; internal const string ValidatorRelevantChanges = "validator.relevant_changes"; + internal const string ValidatorChangedElementsIds = "validator.changed_elements_ids"; internal const string ProcessErrorType = "process.error.type"; internal const string ProcessAction = "process.action"; diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index 77cb50be8..b754d6871 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -12,21 +12,17 @@ public interface IValidationService /// /// Validates the instance with all data elements on the current task and ensures that the instance is ready for process next. /// - /// - /// This method executes validations in the following interfaces - /// * for the current task - /// * for all data elements on the current task - /// * for all data elements with app logic on the current task - /// /// The instance to validate /// instance.Process?.CurrentTask?.ElementId /// Accessor for instance data to be validated + /// List of validators to ignore /// The language to run validations in /// List of validation issues for this data element Task> ValidateInstanceAtTask( Instance instance, string taskId, IInstanceDataAccessor dataAccessor, + List? ignoredValidators, string? language ); diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 911f81046..b05b02478 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -33,6 +33,7 @@ public async Task> ValidateInstanceAtTask( Instance instance, string taskId, IInstanceDataAccessor dataAccessor, + List? ignoredValidators, string? language ) { @@ -43,30 +44,32 @@ public async Task> ValidateInstanceAtTask( // Run task validations (but don't await yet) var validators = _validatorFactory.GetValidators(taskId); - var validationTasks = validators.Select(async v => - { - using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); - try + var validationTasks = validators + .Where(v => ignoredValidators?.Contains(v.ValidationSource, StringComparer.InvariantCulture) ?? true) + .Select(async v => { - var issues = await v.Validate(instance, dataAccessor, taskId, language); - return KeyValuePair.Create( - v.ValidationSource, - issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) - ); - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while running validator {ValidatorName} for task {TaskId} on instance {InstanceId}", - v.ValidationSource, - taskId, - instance.Id - ); - validatorActivity?.Errored(e); - throw; - } - }); + using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); + try + { + var issues = await v.Validate(instance, dataAccessor, taskId, language); + return KeyValuePair.Create( + v.ValidationSource, + issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) + ); + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while running validator {ValidatorName} for task {TaskId} on instance {InstanceId}", + v.ValidationSource, + taskId, + instance.Id + ); + validatorActivity?.Errored(e); + throw; + } + }); var lists = await Task.WhenAll(validationTasks); // Flatten the list of lists to a single list of issues diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index 7dceeba8b..773df1136 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -148,7 +148,7 @@ public async Task ValidateInstance_returns_OK_with_messages() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) .ReturnsAsync(validationResult); // Act @@ -186,7 +186,7 @@ public async Task ValidateInstance_returns_403_when_not_authorized() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) .Throws(exception); // Act @@ -224,7 +224,7 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null)) + .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) .Throws(exception); // Act diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index eaa2cc4fc..2f4b1c567 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -253,6 +253,7 @@ private void SetupMocks(string app, string org, int instanceOwnerId, ValidateDat testScenario.ReceivedInstance, "Task_1", It.IsAny(), + null, null ) ) diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 34b8d6135..51d7750a4 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -4742,6 +4742,13 @@ "format": "uuid" } }, + { + "name": "ignoredValidators", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "language", "in": "query", diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 90377f363..77c37d8c5 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -2888,6 +2888,10 @@ paths: schema: type: string format: uuid + - name: ignoredValidators + in: query + schema: + type: string - name: language in: query schema: diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs index 74eb9dc28..2f2cb47a3 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs @@ -78,6 +78,7 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() instance, "Task_1", dataAccessor.Object, + null, null ); @@ -106,6 +107,7 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( instance, "Task_1", dataAccessorMock.Object, + null, null ); @@ -134,6 +136,7 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() instance, "Task_1", dataAccessorMock.Object, + null, null ); @@ -161,6 +164,7 @@ public async Task FileScanEnabled_Clean_ValidationShouldNotFail() instance, "Task_1", dataAccessorMock.Object, + null, null ); @@ -202,7 +206,13 @@ public async Task ValidateAndUpdateProcess_set_canComplete_validationstatus_and_ }; var dataAccessorMock = new Mock(); - var issues = await validationService.ValidateInstanceAtTask(instance, taskId, dataAccessorMock.Object, null); + var issues = await validationService.ValidateInstanceAtTask( + instance, + taskId, + dataAccessorMock.Object, + null, + null + ); issues.Should().BeEmpty(); // instance.Process?.CurrentTask?.Validated.CanCompleteTask.Should().BeTrue(); @@ -254,7 +264,13 @@ public async Task ValidateAndUpdateProcess_set_canComplete_false_validationstatu }; var dataAccessorMock = new Mock(); - var issues = await validationService.ValidateInstanceAtTask(instance, taskId, dataAccessorMock.Object, null); + var issues = await validationService.ValidateInstanceAtTask( + instance, + taskId, + dataAccessorMock.Object, + null, + null + ); issues.Should().HaveCount(1); issues.Should().ContainSingle(i => i.Code == ValidationIssueCodes.InstanceCodes.TooManyDataElementsOfType); } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 4338e857b..19b58980b 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -274,6 +274,7 @@ public async Task Validate_WithNoValidators_ReturnsNoErrors() _defaultInstance, DefaultTaskId, _dataAccessor, + null, DefaultLanguage ); resultTask.Should().BeEmpty(); @@ -456,6 +457,7 @@ List CreateIssues(string code) _defaultInstance, DefaultTaskId, dataAccessor, + null, DefaultLanguage ); @@ -518,6 +520,7 @@ public async Task ValidateTask_ReturnsNoErrorsFromAllLevels() _defaultInstance, DefaultTaskId, _dataAccessor, + null, DefaultLanguage ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt index 2ee9ccaac..c1c8132c4 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt @@ -1,7 +1,7 @@ [ { Severity: Error, - DataElementId: Guid_1, + DataElementId: MainDataElementGuid, Field: Address, Code: The field Address must be a string or array type with a minimum length of '44'., Description: The field Address must be a string or array type with a minimum length of '44'., @@ -9,7 +9,7 @@ }, { Severity: Error, - DataElementId: Guid_2, + DataElementId: SubForm1_Guid, Field: Address, Code: required, Description: Address is required in component with id subFormLayout.SubPage.Address for binding simpleBinding, @@ -17,7 +17,7 @@ }, { Severity: Error, - DataElementId: Guid_2, + DataElementId: SubForm1_Guid, Field: Name, Code: required, Description: Name is required in component with id subFormLayout.SubPage.Name for binding simpleBinding, @@ -25,7 +25,15 @@ }, { Severity: Error, - DataElementId: Guid_2, + DataElementId: SubForm1_Guid, + Field: Name, + Code: The Name field is required., + Description: The Name field is required., + Source: DataAnnotations + }, + { + Severity: Error, + DataElementId: SubForm1_Guid, Field: Phone, Code: required, Description: Phone is required in component with id subFormLayout.SubPage.Phone for binding simpleBinding, @@ -33,10 +41,50 @@ }, { Severity: Error, - DataElementId: Guid_3, + DataElementId: SubForm2_Guid, + Field: Email, + Code: required, + Description: Email is required in component with id subFormLayout.SubPage.Email for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: SubForm2_Guid, Field: Phone, Code: The field Phone must match the regular expression '^\+47\d+'., Description: The field Phone must match the regular expression '^\+47\d+'., Source: DataAnnotations + }, + { + Severity: Error, + DataElementId: SubForm3_Guid, + Field: Address, + Code: required, + Description: Address is required in component with id subFormLayout.SubPage.Address for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: SubForm3_Guid, + Field: Name, + Code: required, + Description: Name is required in component with id subFormLayout.SubPage.Name for binding simpleBinding, + Source: Required + }, + { + Severity: Error, + DataElementId: SubForm3_Guid, + Field: Name, + Code: The Name field is required., + Description: The Name field is required., + Source: DataAnnotations + }, + { + Severity: Error, + DataElementId: SubForm3_Guid, + Field: Phone, + Code: required, + Description: Phone is required in component with id subFormLayout.SubPage.Phone for binding simpleBinding, + Source: Required } ] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs index da29559e6..bfa72b993 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using Altinn.App.Common.Tests; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; @@ -14,7 +16,6 @@ using Altinn.App.Core.Tests.Features.Validators.Default; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.Platform.Storage.Interface.Models; -using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.DependencyInjection; @@ -31,14 +32,15 @@ private record MainFormModel( [Required] string? Name, [MinLength(44)] string? Address, string? Phone, - string? Email = null + string? Email ); private record SubFormModel( - string? Name, + [Required] string? Name, string? Address, [RegularExpression(@"^\+47\d+")] string? Phone, - string? Email = null + string? Email, + bool? RequireEmail = false ); private readonly ITestOutputHelper _output; @@ -58,6 +60,8 @@ private record SubFormModel( private static readonly Guid _mainDataElementGuid = Guid.Parse("12345678-1234-1234-1234-123456789013"); private static readonly Guid _subFormGuid1 = Guid.Parse("12345678-1234-1234-1234-123456789014"); private static readonly Guid _subFormGuid2 = Guid.Parse("12345678-1234-1234-1234-123456789015"); + private static readonly Guid _subFormGuid3 = Guid.Parse("12345678-1234-1234-1234-123456789016"); + private readonly Instance _instance = new() { @@ -69,7 +73,8 @@ private record SubFormModel( [ new DataElement() { Id = $"{_mainDataElementGuid}", DataType = DefaultDataType }, new DataElement() { Id = $"{_subFormGuid1}", DataType = SubformDataType }, - new DataElement() { Id = $"{_subFormGuid2}", DataType = SubformDataType } + new DataElement() { Id = $"{_subFormGuid2}", DataType = SubformDataType }, + new DataElement() { Id = $"{_subFormGuid3}", DataType = SubformDataType } ], }; @@ -94,6 +99,7 @@ private record SubFormModel( }, ] }; + private readonly IOptions _generalSettings = Options.Create(new GeneralSettings()); private static readonly LayoutSetComponent _mainLayoutComponent = @@ -145,6 +151,7 @@ private record SubFormModel( MainLayoutId, _applicationMetadata.DataTypes[0] ); + private static readonly LayoutSetComponent _subLayoutComponent = new( [ @@ -179,6 +186,14 @@ private record SubFormModel( "simpleBinding": "Phone" }, "required": true + }, + { + "id": "Email", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Email" + }, + "required": ["dataModel", "RequireEmail"] } ] }} @@ -194,7 +209,25 @@ private record SubFormModel( private readonly Mock _httpContextAccessorMock = new(MockBehavior.Loose); private readonly IServiceCollection _services = new ServiceCollection(); - private static readonly JsonSerializerOptions _options = new JsonSerializerOptions { WriteIndented = true }; + private static readonly JsonSerializerOptions _options = + new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private VerifySettings _verifySettings + { + get + { + var settings = new VerifySettings(); + settings.AddNamedGuid(_mainDataElementGuid, "MainDataElementGuid"); + settings.AddNamedGuid(_subFormGuid1, "SubForm1_Guid"); + settings.AddNamedGuid(_subFormGuid2, "SubForm2_Guid"); + settings.AddNamedGuid(_subFormGuid3, "SubForm3_Guid"); + return settings; + } + } public SubFormTests(ITestOutputHelper output, DataAnnotationsTestFixture fixture) { @@ -226,16 +259,17 @@ public async Task Test1() var validationService = serviceProvider.GetRequiredService(); var dataAccessor = new InstanceDataAccessorFake(_instance) { - { _instance.Data[0], new MainFormModel("Name", "Address", "Phone") }, - { _instance.Data[1], new SubFormModel(null, null, null) }, - { _instance.Data[2], new SubFormModel("Name2", "Address2", "Phone2") } + { _instance.Data[0], new MainFormModel("Name", "Address", "Phone", null) }, + { _instance.Data[1], new SubFormModel(null, null, null, null, false) }, + { _instance.Data[2], new SubFormModel("Name2", "Address2", "Phone2", null, true) }, + { _instance.Data[3], new SubFormModel(null, null, null, null, null) } }; - var issues = await validationService.ValidateInstanceAtTask(_instance, TaskId, dataAccessor, null); + var issues = await validationService.ValidateInstanceAtTask(_instance, TaskId, dataAccessor, null, null); _output.WriteLine(JsonSerializer.Serialize(issues, _options)); // Order of issues is not guaranteed, so we sort them before verification - await Verify(issues.OrderBy(i => JsonSerializer.Serialize(i))); + await Verify(issues.OrderBy(i => JsonSerializer.Serialize(i)), _verifySettings); } private static PageComponent ParsePage(string layoutId, string pageName, [StringSyntax("json")] string json) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index 00b37d8e9..5d744d1c1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -8,6 +8,7 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test1; public class RunTest1 { + // Functionality for validation data model references has been removed, but might be reintroduced in the future // [Fact] // public async Task ValidateDataModel() // { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index 255e01836..c4549c1ee 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -8,6 +8,7 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test2; public class RunTest2 { + // Functionality for validation data model references has been removed, but might be reintroduced in the future // [Fact] // public async Task ValidateDataModel() // { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index f8dfcadb2..5aa87f5f5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -8,6 +8,7 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test3; public class RunTest3 { + // Functionality for validation data model references has been removed, but might be reintroduced in the future // [Fact] // public async Task ValidateDataModel() // { From 259212cdf96f3e7b20bb5eefeec512b4e44beff0 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 16:09:36 +0200 Subject: [PATCH 38/63] Cleanup validatorService. Add new tests and telemetry --- .../Controllers/ActionsController.cs | 2 +- .../Controllers/ProcessController.cs | 6 +- .../Controllers/ValidateController.cs | 6 +- .../Telemetry/Telemetry.Validation.cs | 24 +- .../Features/Telemetry/Telemetry.cs | 4 +- .../Wrappers/DataElementValidatorWrapper.cs | 3 + .../Wrappers/FormDataValidatorWrapper.cs | 2 + .../Internal/Patch/PatchService.cs | 2 +- .../Internal/Validation/IValidationService.cs | 25 +- .../Internal/Validation/ValidationService.cs | 77 ++-- .../Models/Layout/PageComponentConverter.cs | 2 +- ...dator_ReturnsValidationErrors.verified.txt | 25 +- ...sNext_PdfFails_DataIsUnlocked.verified.txt | 22 +- .../Controllers/ValidateControllerTests.cs | 6 +- .../ValidateControllerValidateDataTests.cs | 2 +- .../ValidationServiceOldTests.cs | 14 +- .../ValidationServiceTests.cs | 13 +- ...lidatorFunctionForIncremental.verified.txt | 65 ++++ ...CallsValidatorFunctionForTask.verified.txt | 43 +++ ...thNoData_ShouldReturnNoIssues.verified.txt | 28 ++ ...ldRunOnlyNonIgnoredValidators.verified.txt | 26 ++ ...thNoData_ShouldReturnNoIssues.verified.txt | 17 + .../Validators/ValidationServiceTestsNew.cs | 344 ++++++++++++++++++ .../FullTests/SubForm/SubFormTests.cs | 4 +- .../TestUtilities/InstanceDataAccessorFake.cs | 32 +- 25 files changed, 696 insertions(+), 98 deletions(-) rename test/Altinn.App.Core.Tests/Features/Validators/{ => LegacyValidationServiceTests}/ValidationServiceOldTests.cs (99%) rename test/Altinn.App.Core.Tests/Features/Validators/{ => LegacyValidationServiceTests}/ValidationServiceTests.cs (99%) create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index 6c6f288af..dc2f37a8e 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -242,9 +242,9 @@ await _dataClient.UpdateData( var taskId = instance.Process.CurrentTask.ElementId; var validationIssues = await _validationService.ValidateIncrementalFormData( instance, + dataAccessor, taskId, changes, - dataAccessor, ignoredValidators, language ); diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 9d792d963..db6d83104 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -252,10 +252,10 @@ [FromRoute] Guid instanceGuid var dataAcceesor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); var validationIssues = await _validationService.ValidateInstanceAtTask( instance, - currentTaskId, dataAcceesor, - ignoredValidators: null, // run full validation - language + currentTaskId, // run full validation + ignoredValidators: null, + language: language ); var success = validationIssues.TrueForAll(v => v.Severity != ValidationIssueSeverity.Error); diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index ef44d6015..fa2600338 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -82,8 +82,8 @@ public async Task ValidateInstance( var ignoredSources = ignoredValidators?.Split(',').ToList(); List messages = await _validationService.ValidateInstanceAtTask( instance, - taskId, dataAccessor, + taskId, ignoredSources, language ); @@ -157,10 +157,10 @@ public async Task ValidateData( // Run validations for all data elements, but only return the issues for the specific data element var issues = await _validationService.ValidateInstanceAtTask( instance, - dataType.TaskId, dataAccessor, + dataType.TaskId, ignoredValidators: null, - language + language: language ); messages.AddRange(issues.Where(i => i.DataElementId == element.Id)); diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs index ace36672f..6bc79b30f 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs @@ -8,34 +8,34 @@ partial class Telemetry { private void InitValidation(InitContext context) { } - internal Activity? StartValidateInstanceAtTaskActivity(Instance instance, string taskId) + internal Activity? StartValidateInstanceAtTaskActivity(string taskId) { ArgumentException.ThrowIfNullOrWhiteSpace(taskId); var activity = ActivitySource.StartActivity($"{Prefix}.ValidateInstanceAtTask"); activity?.SetTaskId(taskId); - activity?.SetInstanceId(instance); return activity; } - internal Activity? StartValidateIncrementalActivity( - Instance instance, - string taskId, - List changes - ) + internal Activity? StartValidateIncrementalActivity(string taskId, List changes) { ArgumentException.ThrowIfNullOrWhiteSpace(taskId); ArgumentNullException.ThrowIfNull(changes); var activity = ActivitySource.StartActivity($"{Prefix}.ValidateIncremental"); activity?.SetTaskId(taskId); - activity?.SetInstanceId(instance); // Log the IDs of the elements that have changed together with their data type // default:123-678-8900-54,group:123-678-8900-55 - activity?.SetTag( - InternalLabels.ValidatorChangedElementsIds, - string.Join(',', changes.Select(c => $"{c.DataElement.DataType}:{c.DataElement.Id}")) - ); + var changesPrefix = "ChangedDataElements"; + var now = DateTimeOffset.UtcNow; + + ActivityTagsCollection tags = new([new($"{changesPrefix}.count", changes.Count)]); + for (var i = 0; i < changes.Count; i++) + { + var change = changes[i]; + tags.Add(new($"{changesPrefix}.{i}.Id", change.DataElement.Id)); + } + activity?.AddEvent(new ActivityEvent(changesPrefix, now, tags)); return activity; } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs index 0ad61eae2..fc1a14933 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs @@ -180,8 +180,10 @@ internal static class InternalLabels internal const string AuthorizerTaskId = "authorization.authorizer.task.id"; internal const string ValidatorType = "validator.type"; internal const string ValidatorSource = "validator.source"; - internal const string ValidatorRelevantChanges = "validator.relevant_changes"; + internal const string ValidatorHasRelevantChanges = "validator.has_relevant_changes"; internal const string ValidatorChangedElementsIds = "validator.changed_elements_ids"; + internal const string ValidatorIssueCount = "validation.issue_count"; + internal const string ValidationTotalIssueCount = "validation.total_issue_count"; internal const string ProcessErrorType = "process.error.type"; internal const string ProcessAction = "process.action"; diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs index 657b73780..02b6c068c 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs @@ -58,6 +58,9 @@ public async Task> Validate( dataType, language ); + + // Assume that issues from a IDataElementValidator is for the data element it was run for + dataElementValidationResult.ForEach(i => i.DataElementId ??= dataElement.Id); issues.AddRange(dataElementValidationResult); } } diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index 4ad4b3a08..7b286441f 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -49,6 +49,8 @@ public async Task> Validate( data, language ); + // Assume issues from a IFormDataValidator are related to the data element + dataElementValidationResult.ForEach(i => i.DataElementId ??= dataElement.Id); issues.AddRange(dataElementValidationResult); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index f48f2d0b2..94a669c79 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -153,9 +153,9 @@ await _dataClient.UpdateData( var validationIssues = await _validationService.ValidateIncrementalFormData( instance, + dataAccessor, instance.Process.CurrentTask.ElementId, changes, - dataAccessor, ignoredValidators, language ); diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index b754d6871..bfcc416af 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -13,34 +13,35 @@ public interface IValidationService /// Validates the instance with all data elements on the current task and ensures that the instance is ready for process next. /// /// The instance to validate - /// instance.Process?.CurrentTask?.ElementId /// Accessor for instance data to be validated - /// List of validators to ignore + /// The task to run validations for (overriding instance.Process?.CurrentTask?.ElementId) + /// List of to ignore /// The language to run validations in /// List of validation issues for this data element Task> ValidateInstanceAtTask( Instance instance, - string taskId, IInstanceDataAccessor dataAccessor, + string taskId, List? ignoredValidators, string? language ); /// - /// + /// Given a list of changes, evaluate and run the relevant validators to get + /// the issues from the validators that might return different results based on the changes. /// - /// - /// - /// - /// List of changed with both previous and next - /// - /// - /// + /// The instance to validate + /// Accessor for instance data to be validated + /// The task to run validations for (overriding instance.Process?.CurrentTask?.ElementId) + /// List of changed data elements and values to forward to + /// List of to ignore + /// The language to run validations in + /// Dictionary where the key is the and the value is the list of issues this validator produces public Task>> ValidateIncrementalFormData( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, List changes, - IInstanceDataAccessor dataAccessor, List? ignoredValidators, string? language ); diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index b05b02478..0903be71b 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -31,8 +31,8 @@ public ValidationService( /// public async Task> ValidateInstanceAtTask( Instance instance, - string taskId, IInstanceDataAccessor dataAccessor, + string taskId, List? ignoredValidators, string? language ) @@ -40,48 +40,53 @@ public async Task> ValidateInstanceAtTask( ArgumentNullException.ThrowIfNull(instance); ArgumentNullException.ThrowIfNull(taskId); - using var activity = _telemetry?.StartValidateInstanceAtTaskActivity(instance, taskId); + using var activity = _telemetry?.StartValidateInstanceAtTaskActivity(taskId); - // Run task validations (but don't await yet) - var validators = _validatorFactory.GetValidators(taskId); - var validationTasks = validators - .Where(v => ignoredValidators?.Contains(v.ValidationSource, StringComparer.InvariantCulture) ?? true) - .Select(async v => + var validators = _validatorFactory + .GetValidators(taskId) + .Where(v => !(ignoredValidators?.Contains(v.ValidationSource, StringComparer.InvariantCulture)) ?? true); + // Start the validation tasks (but don't await yet, so that they can run in parallel) + var validationTasks = validators.Select(async v => + { + using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); + try { - using var validatorActivity = _telemetry?.StartRunValidatorActivity(v); - try - { - var issues = await v.Validate(instance, dataAccessor, taskId, language); - return KeyValuePair.Create( - v.ValidationSource, - issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) - ); - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while running validator {ValidatorName} for task {TaskId} on instance {InstanceId}", - v.ValidationSource, - taskId, - instance.Id - ); - validatorActivity?.Errored(e); - throw; - } - }); + var issues = await v.Validate(instance, dataAccessor, taskId, language); + validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorIssueCount, issues.Count); + return KeyValuePair.Create( + v.ValidationSource, + issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) + ); + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while running validator {ValidatorName} for task {TaskId} on instance {InstanceId}", + v.ValidationSource, + taskId, + instance.Id + ); + validatorActivity?.Errored(e); + throw; + } + }); + + // Wait for all validation tasks to complete var lists = await Task.WhenAll(validationTasks); // Flatten the list of lists to a single list of issues - return lists.SelectMany(x => x.Value).ToList(); + var issues = lists.SelectMany(x => x.Value).ToList(); + activity?.SetTag(Telemetry.InternalLabels.ValidationTotalIssueCount, issues.Count); + return issues; } /// public async Task>> ValidateIncrementalFormData( Instance instance, + IInstanceDataAccessor dataAccessor, string taskId, List changes, - IInstanceDataAccessor dataAccessor, List? ignoredValidators, string? language ) @@ -90,7 +95,7 @@ public async Task>> ValidateI ArgumentNullException.ThrowIfNull(taskId); ArgumentNullException.ThrowIfNull(changes); - using var activity = _telemetry?.StartValidateIncrementalActivity(instance, taskId, changes); + using var activity = _telemetry?.StartValidateIncrementalActivity(taskId, changes); var validators = _validatorFactory .GetValidators(taskId) @@ -99,17 +104,18 @@ public async Task>> ValidateI ThrowIfDuplicateValidators(validators, taskId); - // Run task validations (but don't await yet) + // Start the validation tasks (but don't await yet, so that they can run in parallel) var validationTasks = validators.Select(async validator => { using var validatorActivity = _telemetry?.StartRunValidatorActivity(validator); try { var hasRelevantChanges = await validator.HasRelevantChanges(instance, taskId, changes, dataAccessor); - validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorRelevantChanges, hasRelevantChanges); + validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorHasRelevantChanges, hasRelevantChanges); if (hasRelevantChanges) { var issues = await validator.Validate(instance, dataAccessor, taskId, language); + validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorIssueCount, issues.Count); var issuesWithSource = issues .Select(i => ValidationIssueWithSource.FromIssue(i, validator.ValidationSource)) .ToList(); @@ -135,7 +141,10 @@ public async Task>> ValidateI } }); + // Wait for all validation tasks to complete var lists = await Task.WhenAll(validationTasks); + var errorCount = lists.Sum(k => k.Value?.Count ?? 0); + activity?.SetTag(Telemetry.InternalLabels.ValidationTotalIssueCount, errorCount); // ! Value is null if no relevant changes. Filter out these before return with ! because ofType don't filter nullables. return lists.Where(k => k.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value!); diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index 1eb6e3b07..296dd6342 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -375,7 +375,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt secure = reader.TokenType == JsonTokenType.True; break; // subform - case "layoutsetid": + case "layoutset": layoutSetId = reader.GetString(); break; // case "tablecolumns": diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index ce1503c9a..2c7c44dee 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -112,6 +112,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Required }, @@ -124,6 +127,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Expression }, @@ -136,6 +142,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* }, @@ -148,6 +157,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* }, @@ -160,6 +172,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: DataAnnotations }, @@ -172,6 +187,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Not a valid validation source }, @@ -184,6 +202,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 1 + }, { validator.source: test-source }, @@ -197,10 +218,10 @@ ActivityName: Validation.ValidateInstanceAtTask, Tags: [ { - instance.guid: Guid_1 + task.id: Task_1 }, { - task.id: Task_1 + validation.total_issue_count: 1 } ], IdFormat: W3C diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index a01da51a9..704bd1768 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -207,6 +207,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Required }, @@ -219,6 +222,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Expression }, @@ -231,6 +237,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Altinn.App.Core.Features.Validation.Default.DefaultTaskValidator-* }, @@ -243,6 +252,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Altinn.App.Core.Features.Validation.Default.DefaultDataElementValidator-* }, @@ -255,6 +267,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: DataAnnotations }, @@ -267,6 +282,9 @@ { ActivityName: Validation.RunValidator, Tags: [ + { + validation.issue_count: 0 + }, { validator.source: Not a valid validation source }, @@ -280,10 +298,10 @@ ActivityName: Validation.ValidateInstanceAtTask, Tags: [ { - instance.guid: Guid_1 + task.id: Task_1 }, { - task.id: Task_1 + validation.total_issue_count: 0 } ], IdFormat: W3C diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index 773df1136..c718dbb7d 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -148,7 +148,7 @@ public async Task ValidateInstance_returns_OK_with_messages() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) + .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) .ReturnsAsync(validationResult); // Act @@ -186,7 +186,7 @@ public async Task ValidateInstance_returns_403_when_not_authorized() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) + .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) .Throws(exception); // Act @@ -224,7 +224,7 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, "dummy", It.IsAny(), null, null)) + .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) .Throws(exception); // Act diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 2f4b1c567..30dd0186e 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -251,8 +251,8 @@ private void SetupMocks(string app, string org, int instanceOwnerId, ValidateDat .Setup(v => v.ValidateInstanceAtTask( testScenario.ReceivedInstance, - "Task_1", It.IsAny(), + "Task_1", null, null ) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs similarity index 99% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs rename to test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs index 2f2cb47a3..a7e45c1bc 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs @@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging; using Moq; -namespace Altinn.App.Core.Tests.Features.Validators; +namespace Altinn.App.Core.Tests.Features.Validators.LegacyValidationServiceTests; public class ValidationServiceOldTests { @@ -76,8 +76,8 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() List validationIssues = await validationService.ValidateInstanceAtTask( instance, - "Task_1", dataAccessor.Object, + "Task_1", null, null ); @@ -105,8 +105,8 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( List validationIssues = await validationService.ValidateInstanceAtTask( instance, - "Task_1", dataAccessorMock.Object, + "Task_1", null, null ); @@ -134,8 +134,8 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() List validationIssues = await validationService.ValidateInstanceAtTask( instance, - "Task_1", dataAccessorMock.Object, + "Task_1", null, null ); @@ -162,8 +162,8 @@ public async Task FileScanEnabled_Clean_ValidationShouldNotFail() List validationIssues = await validationService.ValidateInstanceAtTask( instance, - "Task_1", dataAccessorMock.Object, + "Task_1", null, null ); @@ -208,8 +208,8 @@ public async Task ValidateAndUpdateProcess_set_canComplete_validationstatus_and_ var issues = await validationService.ValidateInstanceAtTask( instance, - taskId, dataAccessorMock.Object, + taskId, null, null ); @@ -266,8 +266,8 @@ public async Task ValidateAndUpdateProcess_set_canComplete_false_validationstatu var issues = await validationService.ValidateInstanceAtTask( instance, - taskId, dataAccessorMock.Object, + taskId, null, null ); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs similarity index 99% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs rename to test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index 19b58980b..da871c486 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -9,13 +9,12 @@ using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Moq; -namespace Altinn.App.Core.Tests.Features.Validators; +namespace Altinn.App.Core.Tests.Features.Validators.LegacyValidationServiceTests; public class ValidationServiceTests : IDisposable { @@ -272,8 +271,8 @@ public async Task Validate_WithNoValidators_ReturnsNoErrors() var resultTask = await validatorService.ValidateInstanceAtTask( _defaultInstance, - DefaultTaskId, _dataAccessor, + DefaultTaskId, null, DefaultLanguage ); @@ -315,6 +314,7 @@ public async Task ValidateFormData_WithSpecificValidator() SetupDataClient(data); var result = await validatorService.ValidateIncrementalFormData( _defaultInstance, + _dataAccessor, "Task_1", new List { @@ -325,7 +325,6 @@ public async Task ValidateFormData_WithSpecificValidator() CurrentValue = data } }, - _dataAccessor, null, DefaultLanguage ); @@ -369,6 +368,7 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa dataAccessor.Set(_defaultDataElement, data); var resultData = await validatorService.ValidateIncrementalFormData( _defaultInstance, + dataAccessor, "Task_1", [ new DataElementChange() @@ -378,7 +378,6 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa PreviousValue = data, } ], - dataAccessor, null, DefaultLanguage ); @@ -455,8 +454,8 @@ List CreateIssues(string code) var taskResult = await validationService.ValidateInstanceAtTask( _defaultInstance, - DefaultTaskId, dataAccessor, + DefaultTaskId, null, DefaultLanguage ); @@ -518,8 +517,8 @@ public async Task ValidateTask_ReturnsNoErrorsFromAllLevels() var result = await validationService.ValidateInstanceAtTask( _defaultInstance, - DefaultTaskId, _dataAccessor, + DefaultTaskId, null, DefaultLanguage ); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt new file mode 100644 index 000000000..126b1cb9f --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt @@ -0,0 +1,65 @@ +{ + telemetry: { + Activities: [ + { + ActivityName: Validation.RunValidator, + Tags: [ + { + validation.issue_count: 1 + }, + { + validator.has_relevant_changes: True + }, + { + validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + }, + { + validator.type: FormDataValidatorWrapper + } + ], + IdFormat: W3C + }, + { + ActivityName: Validation.ValidateIncremental, + Tags: [ + { + task.id: Task_1 + }, + { + validation.total_issue_count: 1 + } + ], + IdFormat: W3C, + Events: [ + { + Name: ChangedDataElements, + Timestamp: DateTimeOffset_1, + Tags: [ + { + ChangedDataElements.count: 2 + }, + { + ChangedDataElements.0.Id: DataElementId_0 + }, + { + ChangedDataElements.1.Id: DataElementId_1 + } + ] + } + ] + } + ], + Metrics: [] + }, + issues: { + Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default: [ + { + Severity: Error, + DataElementId: DataElementId_0, + Code: TestCode, + Description: Test error, + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt new file mode 100644 index 000000000..f3111ac13 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt @@ -0,0 +1,43 @@ +{ + telemetry: { + Activities: [ + { + ActivityName: Validation.RunValidator, + Tags: [ + { + validation.issue_count: 1 + }, + { + validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + }, + { + validator.type: FormDataValidatorWrapper + } + ], + IdFormat: W3C + }, + { + ActivityName: Validation.ValidateInstanceAtTask, + Tags: [ + { + task.id: Task_1 + }, + { + validation.total_issue_count: 1 + } + ], + IdFormat: W3C + } + ], + Metrics: [] + }, + issues: [ + { + Severity: Error, + DataElementId: DataElementId_0, + Code: TestCode, + Description: Test error, + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + } + ] +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt new file mode 100644 index 000000000..8993435cb --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt @@ -0,0 +1,28 @@ +{ + Activities: [ + { + ActivityName: Validation.ValidateIncremental, + Tags: [ + { + task.id: Task_1 + }, + { + validation.total_issue_count: 0 + } + ], + IdFormat: W3C, + Events: [ + { + Name: ChangedDataElements, + Timestamp: DateTimeOffset_1, + Tags: [ + { + ChangedDataElements.count: 0 + } + ] + } + ] + } + ], + Metrics: [] +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt new file mode 100644 index 000000000..2de5e8955 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt @@ -0,0 +1,26 @@ +{ + Activities: [ + { + ActivityName: Validation.RunValidator, + Tags: [ + { + validator.source: Validator + }, + { + validator.type: IValidatorProxy + } + ], + IdFormat: W3C + }, + { + ActivityName: Validation.ValidateInstanceAtTask, + Tags: [ + { + task.id: Task_1 + } + ], + IdFormat: W3C + } + ], + Metrics: [] +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt new file mode 100644 index 000000000..3a9e75623 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt @@ -0,0 +1,17 @@ +{ + Activities: [ + { + ActivityName: Validation.ValidateInstanceAtTask, + Tags: [ + { + task.id: Task_1 + }, + { + validation.total_issue_count: 0 + } + ], + IdFormat: W3C + } + ], + Metrics: [] +} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs new file mode 100644 index 000000000..6feb53fb6 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs @@ -0,0 +1,344 @@ +using Altinn.App.Common.Tests; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Validation; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Validation; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Validation; +using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit.Abstractions; +using Exception = System.Exception; + +namespace Altinn.App.Core.Tests.Features.Validators; + +public class ValidationServiceTestsNew : IAsyncLifetime +{ + private const string Org = "ttd"; + private const string App = "app"; + private const string TaskId = "Task_1"; + + private readonly ApplicationMetadata _appMetadata = new($"{Org}/{App}") { DataTypes = [] }; + + private readonly Instance _instance = + new() + { + AppId = $"{Org}/{App}", + Org = Org, + Data = [] + }; + + private readonly Mock _appMetadataMock = new(MockBehavior.Strict); + private readonly InstanceDataAccessorFake _instanceDataAccessor; + private readonly IServiceCollection _services = new ServiceCollection(); + private readonly Lazy _serviceProvider; + + public ValidationServiceTestsNew(ITestOutputHelper output) + { + _instanceDataAccessor = new InstanceDataAccessorFake(_instance, _appMetadata, TaskId); + _services.AddScoped(); + _services.AddTelemetrySink(); + _services.AddFakeLoggingWithXunit(output); + _services.AddScoped(); + _services.AddSingleton(_appMetadataMock.Object); + + _appMetadataMock.Setup(am => am.GetApplicationMetadata()).ReturnsAsync(_appMetadata); + _serviceProvider = new(() => _services.BuildServiceProvider()); + } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + private Mock RegistrerValidatorMock( + string source, + bool? hasRelevantChanges = null, + List? expectedChanges = null, + List? issues = null, + string? expectedLanguage = null + ) + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(v => v.ValidationSource).Returns(source); + if (hasRelevantChanges.HasValue && expectedChanges is not null) + { + mock.Setup(v => v.HasRelevantChanges(_instance, "Task_1", expectedChanges, _instanceDataAccessor)) + .ReturnsAsync(hasRelevantChanges.Value); + } + + if (issues is not null) + { + mock.Setup(v => v.Validate(_instance, _instanceDataAccessor, "Task_1", expectedLanguage)) + .ReturnsAsync(issues); + } + + _services.AddSingleton(mock.Object); + return mock; + } + + [Fact] + public async Task ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues() + { + // Arrange + + // Act + var validationService = _serviceProvider.Value.GetRequiredService(); + var result = await validationService.ValidateInstanceAtTask( + _instance, + _instanceDataAccessor, + "Task_1", + null, + null + ); + + // Assert + Assert.Empty(result); + var telemetry = _serviceProvider.Value.GetRequiredService(); + await Verify(telemetry.GetSnapshot()); + } + + [Fact] + public async Task ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues() + { + var validationService = _serviceProvider.Value.GetRequiredService(); + + var changes = new List(); + var result = await validationService.ValidateIncrementalFormData( + _instance, + _instanceDataAccessor, + "Task_1", + changes, + null, + null + ); + + Assert.Empty(result); + var telemetry = _serviceProvider.Value.GetRequiredService(); + await Verify(telemetry.GetSnapshot()); + } + + [Fact] + public async Task ValidateIncrementalFormData_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators() + { + var changes = new List(); + var issues = new List(); + + RegistrerValidatorMock("IgnoredValidator"); // Throws error if changes or validation is called + RegistrerValidatorMock("Validator", hasRelevantChanges: true, changes, issues); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var result = await validationService.ValidateIncrementalFormData( + _instance, + _instanceDataAccessor, + "Task_1", + changes, + new List { "IgnoredValidator" }, + null + ); + result.Should().ContainKey("Validator").WhoseValue.Should().BeEmpty(); + } + + [Fact] + public async Task ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators() + { + var language = "esperanto"; + var issue = new ValidationIssue() + { + Severity = ValidationIssueSeverity.Error, + Description = "Test error", + Code = "TestCode" + }; + + RegistrerValidatorMock(source: "IgnoredValidator"); + RegistrerValidatorMock( + source: "Validator", + hasRelevantChanges: true, + issues: [issue], + expectedLanguage: language + ); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var result = await validationService.ValidateInstanceAtTask( + _instance, + _instanceDataAccessor, + "Task_1", + new List { "IgnoredValidator" }, + language + ); + var issueWithSource = result.Should().ContainSingle().Which; + issueWithSource.Source.Should().Be("Validator"); + issueWithSource.Code.Should().Be(issue.Code); + issueWithSource.Description.Should().Be(issue.Description); + issueWithSource.Severity.Should().Be(issue.Severity); + issueWithSource.DataElementId.Should().BeNull(); + } + + private class GenericValidatorFake : GenericFormDataValidator + { + private readonly IEnumerable _issues; + private readonly bool? _hasRelevantChanges; + + public GenericValidatorFake( + string dataType, + IEnumerable issues, + bool? hasRelevantChanges = null + ) + : base(dataType) + { + _issues = issues; + _hasRelevantChanges = hasRelevantChanges; + } + + protected override Task ValidateFormData( + Instance instance, + DataElement dataElement, + string data, + string? language + ) + { + foreach (var issue in _issues) + { + AddValidationIssue(issue); + } + + return Task.CompletedTask; + } + + protected override bool HasRelevantChanges(string current, string previous) + { + return _hasRelevantChanges ?? throw new Exception("Has relevant changes not set"); + } + } + + [Fact] + public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask() + { + var valueToValidate = "valueToValidate"; + var defaultDataType = "default"; + var taskId = "Task_1"; + + var dataElement = new DataElement { Id = Guid.NewGuid().ToString(), DataType = defaultDataType }; + _instanceDataAccessor.Add(dataElement, valueToValidate); + + List validatorIssues = + [ + new() + { + Severity = ValidationIssueSeverity.Error, + Description = "Test error", + Code = "TestCode" + } + ]; + + var genericValidator = new GenericValidatorFake(defaultDataType, validatorIssues); + _services.AddSingleton(genericValidator); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var issues = await validationService.ValidateInstanceAtTask( + _instance, + _instanceDataAccessor, + taskId, + null, + null + ); + var issue = issues.Should().ContainSingle().Which; + issue.Source.Should().Be($"{genericValidator.GetType().FullName}-{defaultDataType}"); + issue.DataElementId.Should().Be(dataElement.Id); + issue.Code.Should().Be("TestCode"); + + var telemetry = _serviceProvider.Value.GetRequiredService(); + + var verifySettings = GetVerifySettings(); + await Verify(new { telemetry = telemetry.GetSnapshot(), issues = issues }, verifySettings); + } + + [Fact] + public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental() + { + var valueToValidate = "valueToValidate"; + var defaultDataType = "default"; + var dataTypeNoValidation = "dataTypeToNotValidate"; + var taskId = "Task_1"; + _appMetadata.DataTypes.Add( + new DataType + { + Id = defaultDataType, + TaskId = taskId, + AppLogic = new() { ClassRef = valueToValidate.GetType().FullName } + } + ); + var dataElement = new DataElement { Id = Guid.NewGuid().ToString(), DataType = defaultDataType }; + _instanceDataAccessor.Add(dataElement, valueToValidate); + var dataElementNoValidation = new DataElement() + { + Id = Guid.NewGuid().ToString(), + DataType = dataTypeNoValidation + }; + _instanceDataAccessor.Add(dataElementNoValidation, "valueToNotValidate"); + + List validatorIssues = + [ + new() + { + Severity = ValidationIssueSeverity.Error, + Description = "Test error", + Code = "TestCode" + } + ]; + + List changes = + new() + { + new DataElementChange() + { + DataElement = dataElement, + CurrentValue = "currentValue", + PreviousValue = "previousValue" + }, + new DataElementChange() + { + DataElement = dataElementNoValidation, + CurrentValue = "currentValue", + PreviousValue = "previousValue" + } + }; + + var genericValidator = new GenericValidatorFake(defaultDataType, validatorIssues, hasRelevantChanges: true); + _services.AddSingleton(genericValidator); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var issues = await validationService.ValidateIncrementalFormData( + _instance, + _instanceDataAccessor, + taskId, + changes, + null, + null + ); + var issue = issues.Should().ContainSingle().Which; + + var telemetry = _serviceProvider.Value.GetRequiredService(); + + var verifySettings = GetVerifySettings(); + await Verify(new { telemetry = telemetry.GetSnapshot(), issues = issues }, verifySettings); + } + + private VerifySettings GetVerifySettings() + { + var verifySettings = new VerifySettings(); + int dataElementIndex = 0; + _instance.Data.ForEach( + (d) => verifySettings.AddNamedGuid(Guid.Parse(d.Id), $"DataElementId_{dataElementIndex++}") + ); + return verifySettings; + } + + public async Task DisposeAsync() + { + await _serviceProvider.Value.DisposeAsync(); + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs index bfa72b993..bfb0b741c 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -140,7 +140,7 @@ private record SubFormModel( { "id": "SubForm", "type": "SubForm", - "layoutSetId": "{{SubLayoutId}}" + "layoutSet": "{{SubLayoutId}}" } ] } @@ -265,7 +265,7 @@ public async Task Test1() { _instance.Data[3], new SubFormModel(null, null, null, null, null) } }; - var issues = await validationService.ValidateInstanceAtTask(_instance, TaskId, dataAccessor, null, null); + var issues = await validationService.ValidateInstanceAtTask(_instance, dataAccessor, TaskId, null, null); _output.WriteLine(JsonSerializer.Serialize(issues, _options)); // Order of issues is not guaranteed, so we sort them before verification diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 9bce9145f..483f4cb88 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -8,10 +8,19 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; public class InstanceDataAccessorFake : IInstanceDataAccessor, IEnumerable> { private readonly ApplicationMetadata? _applicationMetadata; + private readonly string? _defaultTaskId; + private readonly string _defaultDataType; - public InstanceDataAccessorFake(Instance instance, ApplicationMetadata? applicationMetadata = null) + public InstanceDataAccessorFake( + Instance instance, + ApplicationMetadata? applicationMetadata = null, + string defaultTaskId = "Task_1", + string defaultDataType = "default" + ) { _applicationMetadata = applicationMetadata; + _defaultTaskId = defaultTaskId; + _defaultDataType = defaultDataType; Instance = instance; Instance.Data ??= new(); } @@ -20,10 +29,10 @@ public InstanceDataAccessorFake(Instance instance, ApplicationMetadata? applicat private readonly Dictionary _dataByType = new(); private readonly List> _data = new(); - public void Add(DataElement? dataElement, object data) + public void Add(DataElement? dataElement, object data, int maxCount = 1) { dataElement ??= new DataElement(); - dataElement.DataType ??= "default"; + dataElement.DataType ??= _defaultDataType; dataElement.Id ??= Guid.NewGuid().ToString(); if (!Instance.Data.Contains(dataElement)) { @@ -33,9 +42,20 @@ public void Add(DataElement? dataElement, object data) _dataById.Add(dataElement, data); if (_applicationMetadata is not null) { - var dataType = - _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType) - ?? throw new ArgumentException($"Data type {dataElement.DataType} not found in application metadata"); + var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType is null) + { + // Create a new dataType if it doesn't exist in the application metadata yet + dataType = new DataType() + { + Id = dataElement.DataType, + TaskId = _defaultTaskId, + AppLogic = new() { ClassRef = data.GetType().FullName }, + MaxCount = maxCount + }; + _applicationMetadata.DataTypes.Add(dataType); + } + if (dataType.AppLogic is not null) { if (dataType.AppLogic.ClassRef != data.GetType().FullName) From 1081d5f76c2069fc53a77e75e5132d44a017bdc0 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 20:23:05 +0200 Subject: [PATCH 39/63] Make sonar cloud complaints editor warnings instead of suggestions * Unused usings * Mark members static Much easier to see theese issues before sonar cloud runs --- .editorconfig | 4 ++-- .gitignore | 3 +++ .../Extensions/WebHostBuilderExtensions.cs | 2 -- .../Converters/JsonWebKeyConverter.cs | 1 - .../Features/Telemetry/Telemetry.Validation.cs | 3 +-- .../Implementation/AppResourcesSI.cs | 4 ++-- src/Altinn.App.Core/Implementation/PrefillSI.cs | 2 +- .../Clients/Register/PersonClient.cs | 2 +- .../Models/Layout/PageComponentConverter.cs | 15 ++++++++------- .../Controllers/DataController_UserAccessTests.cs | 1 - .../Process/ExpressionsExclusiveGatewayTests.cs | 3 --- .../CommonTests/ContextListRoot.cs | 1 - .../CommonTests/LayoutModelConverterFromObject.cs | 4 +--- test/Directory.Build.props | 6 ++++-- 14 files changed, 23 insertions(+), 28 deletions(-) diff --git a/.editorconfig b/.editorconfig index 09294cd3e..a21327319 100644 --- a/.editorconfig +++ b/.editorconfig @@ -111,7 +111,7 @@ csharp_style_namespace_declarations = file_scoped:error dotnet_diagnostic.IDE1006.severity = error # Unused usings -dotnet_diagnostic.IDE0005.severity = suggestion +dotnet_diagnostic.IDE0005.severity = warning # CA1848: Use the LoggerMessage delegates dotnet_diagnostic.CA1848.severity = none @@ -123,7 +123,7 @@ dotnet_diagnostic.CA1727.severity = suggestion dotnet_diagnostic.CA2254.severity = none # CA1822: Mark members as static -dotnet_diagnostic.CA1822.severity = suggestion +dotnet_diagnostic.CA1822.severity = warning # IDE0080: Remove unnecessary suppression operator dotnet_diagnostic.IDE0080.severity = error diff --git a/.gitignore b/.gitignore index 3335d1a72..8ab5b6359 100644 --- a/.gitignore +++ b/.gitignore @@ -377,3 +377,6 @@ src/Altinn.Apps/AppTemplates/AspNet/App/Properties/launchSettings.json *.iml .mono/ + +*.received.txt +*.recevied.json diff --git a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs index 52bf037b7..e3fb8e9bf 100644 --- a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs @@ -1,6 +1,4 @@ using Altinn.App.Core.Extensions; -using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Models; using Microsoft.Extensions.FileProviders; namespace Altinn.App.Api.Extensions; diff --git a/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs b/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs index 90f5b20be..5b6a54ac9 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/Converters/JsonWebKeyConverter.cs @@ -1,6 +1,5 @@ using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using Altinn.App.Core.Features.Maskinporten.Exceptions; using Altinn.App.Core.Features.Maskinporten.Models; using Microsoft.IdentityModel.Tokens; diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs index 6bc79b30f..e3dc4322b 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs @@ -1,12 +1,11 @@ using System.Diagnostics; -using Altinn.Platform.Storage.Interface.Models; using static Altinn.App.Core.Features.Telemetry.Validation; namespace Altinn.App.Core.Features; partial class Telemetry { - private void InitValidation(InitContext context) { } + private static void InitValidation(InitContext context) { } internal Activity? StartValidateInstanceAtTaskActivity(string taskId) { diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 9a196a53c..2ebf29295 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -432,7 +432,7 @@ public byte[] GetRuleHandlerForSet(string id) return ReadFileByte(filename); } - private byte[] ReadFileByte(string fileName) + private static byte[] ReadFileByte(string fileName) { byte[]? filedata = null; if (File.Exists(fileName)) @@ -445,7 +445,7 @@ private byte[] ReadFileByte(string fileName) #nullable restore } - private byte[] ReadFileContentsFromLegalPath(string legalPath, string filePath) + private static byte[] ReadFileContentsFromLegalPath(string legalPath, string filePath) { var fullFileName = legalPath + filePath; if (!PathHelper.ValidateLegalFilePath(legalPath, fullFileName)) diff --git a/src/Altinn.App.Core/Implementation/PrefillSI.cs b/src/Altinn.App.Core/Implementation/PrefillSI.cs index 895380dc8..19b00756f 100644 --- a/src/Altinn.App.Core/Implementation/PrefillSI.cs +++ b/src/Altinn.App.Core/Implementation/PrefillSI.cs @@ -313,7 +313,7 @@ private void LoopThroughDictionaryAndAssignValuesToDataModel( } } - private Dictionary SwapKeyValuesForPrefill(Dictionary externalPrefil) + private static Dictionary SwapKeyValuesForPrefill(Dictionary externalPrefil) { return externalPrefil.ToDictionary(x => x.Value, x => x.Key); } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Register/PersonClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Register/PersonClient.cs index a34ec5890..846fdfd66 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Register/PersonClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Register/PersonClient.cs @@ -81,7 +81,7 @@ private async Task AddAuthHeaders(HttpRequestMessage request) request.Headers.Add("Authorization", "Bearer " + _userTokenProvider.GetUserToken()); } - private async Task ReadResponse(HttpResponseMessage response, CancellationToken ct) + private static async Task ReadResponse(HttpResponseMessage response, CancellationToken ct) { if (response.StatusCode == HttpStatusCode.OK) { diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index 296dd6342..3122ab8e6 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -50,7 +50,7 @@ public override PageComponent Read(ref Utf8JsonReader reader, Type typeToConvert /// /// Similar to read, but not nullable, and no pageName hack. /// - public PageComponent ReadNotNull( + public static PageComponent ReadNotNull( ref Utf8JsonReader reader, string pageName, string layoutId, @@ -94,7 +94,7 @@ JsonSerializerOptions options return page; } - private PageComponent ReadData( + private static PageComponent ReadData( ref Utf8JsonReader reader, string pageName, string layoutId, @@ -177,10 +177,11 @@ JsonSerializerOptions options ); } - private (List, Dictionary, Dictionary) ReadLayout( - ref Utf8JsonReader reader, - JsonSerializerOptions options - ) + private static ( + List, + Dictionary, + Dictionary + ) ReadLayout(ref Utf8JsonReader reader, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartArray) { @@ -269,7 +270,7 @@ Dictionary childToGroupMapping } } - private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOptions options) + private static BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) { diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs index 4512074ce..14b533818 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_UserAccessTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Headers; -using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; using Altinn.App.Api.Tests.Utils; using Altinn.App.Core.Features; using Altinn.Platform.Storage.Interface.Models; diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 25e36dbab..661273c1b 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -8,9 +8,6 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models; -using Altinn.App.Core.Models.Expressions; -using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Tests.Internal.Process.TestData; using Altinn.Platform.Storage.Interface.Models; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs index fc94447ee..df4ea2b7d 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs index 54c61702a..cf8c1e6dd 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs @@ -49,9 +49,7 @@ JsonSerializerOptions options ); reader.Read(); - var converter = new PageComponentConverter(); - - pages[pageName] = converter.ReadNotNull(ref reader, pageName, "test-layout", options); + pages[pageName] = PageComponentConverter.ReadNotNull(ref reader, pageName, "test-layout", options); } return pages; diff --git a/test/Directory.Build.props b/test/Directory.Build.props index bf019140f..a58e501ab 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -4,12 +4,14 @@ Default - $(NoWarn);CS1591;CS0618;CS7022;CA1707 + $(NoWarn);CS1591;CS0618;CS7022;CA1707;CA1822;CA1727 - \ No newline at end of file + From 18d4169626006bccad16729c945cfb29e096dd17 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 20:48:19 +0200 Subject: [PATCH 40/63] Activate CA1816: Call GC.SuppressFinalize correctly Make it easier to get 0 sonar cloud warnings by making its issues warnings --- .editorconfig | 3 +++ .../Features/Action/UniqueSignatureAuthorizerTests.cs | 2 +- .../Validators/Default/DataAnnotationValidatorTests.cs | 2 +- .../LegacyValidationServiceTests/ValidationServiceTests.cs | 2 +- .../Implementation/InstanceClientTests.cs | 2 +- test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs | 2 +- .../Internal/Process/ProcessEngineTest.cs | 2 +- test/Directory.Build.props | 3 ++- 8 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.editorconfig b/.editorconfig index a21327319..72f9b1207 100644 --- a/.editorconfig +++ b/.editorconfig @@ -146,6 +146,9 @@ dotnet_diagnostic.CA2201.severity = suggestion # TODO: fixing this would be breaking dotnet_diagnostic.CA1720.severity = suggestion +# CA1816: Call GC.SuppressFinalize correctly +dotnet_diagnostic.CA1816.severity = warning + [Program.cs] dotnet_diagnostic.CA1050.severity = none dotnet_diagnostic.S1118.severity = none diff --git a/test/Altinn.App.Core.Tests/Features/Action/UniqueSignatureAuthorizerTests.cs b/test/Altinn.App.Core.Tests/Features/Action/UniqueSignatureAuthorizerTests.cs index 7773e990d..0c82d2e93 100644 --- a/test/Altinn.App.Core.Tests/Features/Action/UniqueSignatureAuthorizerTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Action/UniqueSignatureAuthorizerTests.cs @@ -14,7 +14,7 @@ namespace Altinn.App.Core.Tests.Features.Action; -public class UniqueSignatureAuthorizerTests : IDisposable +public sealed class UniqueSignatureAuthorizerTests : IDisposable { private readonly Mock _processReaderMock; private readonly Mock _instanceClientMock; diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs index 68091279f..382c4dfc9 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/DataAnnotationValidatorTests.cs @@ -168,7 +168,7 @@ public async Task ValidateFormData_RequiredProperty() /// /// A full WebApplicationFactory seemed a little overkill, so we just use a WebApplicationBuilder. /// -public class DataAnnotationsTestFixture : IAsyncDisposable +public sealed class DataAnnotationsTestFixture : IAsyncDisposable { public const string DataType = "test"; diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index da871c486..297ff1e65 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -16,7 +16,7 @@ namespace Altinn.App.Core.Tests.Features.Validators.LegacyValidationServiceTests; -public class ValidationServiceTests : IDisposable +public sealed class ValidationServiceTests : IDisposable { private class MyModel { diff --git a/test/Altinn.App.Core.Tests/Implementation/InstanceClientTests.cs b/test/Altinn.App.Core.Tests/Implementation/InstanceClientTests.cs index 1c4a9ac1a..59ad71b36 100644 --- a/test/Altinn.App.Core.Tests/Implementation/InstanceClientTests.cs +++ b/test/Altinn.App.Core.Tests/Implementation/InstanceClientTests.cs @@ -17,7 +17,7 @@ namespace Altinn.App.PlatformServices.Tests.Implementation; -public class InstanceClientTests : IDisposable +public sealed class InstanceClientTests : IDisposable { private readonly Mock> platformSettingsOptions; private readonly Mock> appSettingsOptions; diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 86fd8b348..a3a5d6fdd 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -21,7 +21,7 @@ namespace Altinn.App.Core.Tests.Internal.Patch; -public class PatchServiceTests : IDisposable +public sealed class PatchServiceTests : IDisposable { // Test data private static readonly Guid _dataGuid = new("12345678-1234-1234-1234-123456789123"); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index 043239c4b..8700aac22 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -20,7 +20,7 @@ namespace Altinn.App.Core.Tests.Internal.Process; -public class ProcessEngineTest : IDisposable +public sealed class ProcessEngineTest : IDisposable { private Mock _processReaderMock; private readonly Mock _profileMock; diff --git a/test/Directory.Build.props b/test/Directory.Build.props index a58e501ab..912572463 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -4,13 +4,14 @@ Default - $(NoWarn);CS1591;CS0618;CS7022;CA1707;CA1822;CA1727 + $(NoWarn);CS1591;CS0618;CS7022;CA1707;CA1822;CA1727;CA2201 From 93caa717d0867ab493763bdb644af0b393816e34 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 21:42:51 +0200 Subject: [PATCH 41/63] Fix more sonar cloud issues --- .../Features/Telemetry/Telemetry.Validation.cs | 5 ++++- test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj | 6 ++++++ ...tring_CallsValidatorFunctionForIncremental.verified.txt} | 6 +++--- ...odelIsString_CallsValidatorFunctionForTask.verified.txt} | 4 ++-- ...alFormData_WithNoData_ShouldReturnNoIssues.verified.txt} | 0 ...lidators_ShouldRunOnlyNonIgnoredValidators.verified.txt} | 0 ...anceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt} | 0 ...lidationServiceTestsNew.cs => ValidationServiceTests.cs} | 4 ++-- 8 files changed, 17 insertions(+), 8 deletions(-) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt => ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt} (90%) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt => ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt} (88%) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt => ValidationServiceTests.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt} (100%) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt => ValidationServiceTests.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt} (100%) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt => ValidationServiceTests.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt} (100%) rename test/Altinn.App.Core.Tests/Features/Validators/{ValidationServiceTestsNew.cs => ValidationServiceTests.cs} (99%) diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs index e3dc4322b..ff41616b0 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs @@ -5,7 +5,10 @@ namespace Altinn.App.Core.Features; partial class Telemetry { - private static void InitValidation(InitContext context) { } + private static void InitValidation(InitContext context) + { + // Currently no initialization is needed + } internal Activity? StartValidateInstanceAtTaskActivity(string taskId) { diff --git a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj index 50b3331ac..e2b4c70b6 100644 --- a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj +++ b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj @@ -103,4 +103,10 @@ + + + ValidationServiceTests.cs + + + diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt similarity index 90% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt index 126b1cb9f..e280b0128 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt @@ -11,7 +11,7 @@ validator.has_relevant_changes: True }, { - validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default }, { validator.type: FormDataValidatorWrapper @@ -52,13 +52,13 @@ Metrics: [] }, issues: { - Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default: [ + Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default: [ { Severity: Error, DataElementId: DataElementId_0, Code: TestCode, Description: Test error, - Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default } ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt similarity index 88% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt index f3111ac13..9ee2d83fb 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt @@ -8,7 +8,7 @@ validation.issue_count: 1 }, { - validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + validator.source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default }, { validator.type: FormDataValidatorWrapper @@ -37,7 +37,7 @@ DataElementId: DataElementId_0, Code: TestCode, Description: Test error, - Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTestsNew+GenericValidatorFake-default + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default } ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateIncrementalFormData_WithNoData_ShouldReturnNoIssues.verified.txt diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonIgnoredValidators.verified.txt diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues.verified.txt diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs similarity index 99% rename from test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs rename to test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 6feb53fb6..281f152de 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTestsNew.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -15,7 +15,7 @@ namespace Altinn.App.Core.Tests.Features.Validators; -public class ValidationServiceTestsNew : IAsyncLifetime +public class ValidationServiceTests : IAsyncLifetime { private const string Org = "ttd"; private const string App = "app"; @@ -36,7 +36,7 @@ public class ValidationServiceTestsNew : IAsyncLifetime private readonly IServiceCollection _services = new ServiceCollection(); private readonly Lazy _serviceProvider; - public ValidationServiceTestsNew(ITestOutputHelper output) + public ValidationServiceTests(ITestOutputHelper output) { _instanceDataAccessor = new InstanceDataAccessorFake(_instance, _appMetadata, TaskId); _services.AddScoped(); From 54c5ffbbea9a6796fb4e3e04452c37c4cdf04b61 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 22:09:27 +0200 Subject: [PATCH 42/63] Make change to IProcessExclusiveGateway backwards compatible --- .../Features/IProcessExclusiveGateway.cs | 24 ++++++++++++++++++- .../Process/ExpressionsExclusiveGateway.cs | 13 ++++++++++ .../Validators/ValidationServiceTests.cs | 2 +- .../Internal/Process/ProcessNavigatorTests.cs | 20 ++++++++++++++++ .../StubGatewayFilters/DataValuesFilter.cs | 10 ++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs b/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs index d3d30bfde..f9147011e 100644 --- a/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs +++ b/src/Altinn.App.Core/Features/IProcessExclusiveGateway.cs @@ -22,10 +22,32 @@ public interface IProcessExclusiveGateway /// Cached accessor for instance.Data /// Information connected with the current gateway under evaluation /// List of possible SequenceFlows to choose out of the gateway - public Task> FilterAsync( + public async Task> FilterAsync( List outgoingFlows, Instance instance, IInstanceDataAccessor dataAccessor, ProcessGatewayInformation processGatewayInformation + ) + { + // TODO: Remmove default implemntation that calls the legacy in v9 +#pragma warning disable CS0618 // Type or member is obsolete + return await FilterAsync(outgoingFlows, instance, processGatewayInformation); +#pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Legacy method for filtering out non viable flows from a gateway with id as defined in . Will add support for in v9 + /// + /// Complete list of defined flows out of gateway + /// Instance where process is about to move next + /// Information connected with the current gateway under evaluation + /// List of possible SequenceFlows to choose out of the gateway + [Obsolete( + "Use FilterAsync(List, Instance, IInstanceDataAccessor, ProcessGatewayInformation) instead" + )] + public Task> FilterAsync( + List outgoingFlows, + Instance instance, + ProcessGatewayInformation processGatewayInformation ); } diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 6a29a1102..5d18ad455 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -118,4 +118,17 @@ private static Expression GetExpressionFromCondition(string condition) var expressionFromCondition = ExpressionConverter.ReadStatic(ref reader, _jsonSerializerOptions); return expressionFromCondition; } + + /// + /// Legacy method kept for backwards compatibility + /// + Task> IProcessExclusiveGateway.FilterAsync( + List outgoingFlows, + Instance instance, + ProcessGatewayInformation processGatewayInformation + ) + { + //TODO: Remove when obsolete method is removed from interface in v9 + throw new NotImplementedException(); + } } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 281f152de..445cb9570 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -319,7 +319,7 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu null, null ); - var issue = issues.Should().ContainSingle().Which; + issues.Should().HaveCount(1); var telemetry = _serviceProvider.Value.GetRequiredService(); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index 5fde6cdf5..2ba546a1b 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -68,6 +68,16 @@ ProcessGatewayInformation processGatewayInformation outgoingFlows.Where(f => f.Id == "Flow_sign1_sign" || f.Id == "Flow_SingleSign").ToList() ); } + + Task> IProcessExclusiveGateway.FilterAsync( + List outgoingFlows, + Instance instance, + ProcessGatewayInformation processGatewayInformation + ) + { + //TODO: Remove legacy method when removed from interface + throw new NotImplementedException(); + } } [Fact] @@ -95,6 +105,16 @@ ProcessGatewayInformation processGatewayInformation { return Task.FromResult(new List()); } + + Task> IProcessExclusiveGateway.FilterAsync( + List outgoingFlows, + Instance instance, + ProcessGatewayInformation processGatewayInformation + ) + { + //TODO: Remove legacy method when removed from interface in v9 + throw new NotImplementedException(); + } } [Fact] diff --git a/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs b/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs index 89f34745d..cc8390afd 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/StubGatewayFilters/DataValuesFilter.cs @@ -27,4 +27,14 @@ ProcessGatewayInformation processGatewayInformation var targetFlow = instance.DataValues[_filterOnDataValue]; return await Task.FromResult(outgoingFlows.FindAll(e => e.Id == targetFlow)); } + + Task> IProcessExclusiveGateway.FilterAsync( + List outgoingFlows, + Instance instance, + ProcessGatewayInformation processGatewayInformation + ) + { + //TODO: Remove when obsolete method is removed from interface + throw new NotImplementedException(); + } } From 450f53d978f52e65c275b8146d4c6ac6ebea1c55 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 10 Sep 2024 22:14:36 +0200 Subject: [PATCH 43/63] Add additional status codes to swagger --- .../Controllers/DataController.cs | 2 + .../Altinn.App.Api.Tests/OpenApi/swagger.json | 60 +++++++++++++++++++ .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 36 +++++++++++ 3 files changed, 98 insertions(+) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 84f78a3a9..753310939 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -503,6 +503,8 @@ public async Task> PatchFormData( [ProducesResponseType(typeof(DataPatchResponseMultiple), 200)] [ProducesResponseType(typeof(ProblemDetails), 409)] [ProducesResponseType(typeof(ProblemDetails), 422)] + [ProducesResponseType(typeof(ProblemDetails), 400)] + [ProducesResponseType(typeof(ProblemDetails), 404)] public async Task> PatchFormDataMultiple( [FromRoute] string org, [FromRoute] string app, diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 51d7750a4..1c5a8a85b 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -734,6 +734,66 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/xml": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 77c37d8c5..e64029def 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -446,6 +446,42 @@ paths: text/xml: schema: $ref: '#/components/schemas/ProblemDetails' + '400': + description: Bad Request + content: + text/plain: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + '404': + description: Not Found + content: + text/plain: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + application/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' + text/xml: + schema: + $ref: '#/components/schemas/ProblemDetails' '/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/data/{dataGuid}': get: tags: From 434528bda317cbff79eeec15c829774e01547734 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Wed, 11 Sep 2024 09:42:27 +0200 Subject: [PATCH 44/63] Update Instance.Data when writing copied data without shadow fields --- ...essor.cs => CachedInstanceDataAccessor.cs} | 0 .../Common/ProcessTaskFinalizer.cs | 3 +- .../Altinn.App.Core.Tests.csproj | 6 --- .../TestUtils/ServiceProviderTests.cs | 48 ------------------- 4 files changed, 2 insertions(+), 55 deletions(-) rename src/Altinn.App.Core/Internal/Data/{CachedFormDataAccessor.cs => CachedInstanceDataAccessor.cs} (100%) delete mode 100644 test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs diff --git a/src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs similarity index 100% rename from src/Altinn.App.Core/Internal/Data/CachedFormDataAccessor.cs rename to src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 67eb43d01..2617e3535 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -173,7 +173,7 @@ private async Task RemoveFieldsOnTaskComplete( Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); string app = instance.AppId.Split("/")[1]; int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); - await _dataClient.InsertFormData( + var newDataElement = await _dataClient.InsertFormData( updatedData, instanceGuid, saveToModelType, @@ -182,6 +182,7 @@ await _dataClient.InsertFormData( instanceOwnerPartyId, saveToDataType.Id ); + instance.Data.Add(newDataElement); } else { diff --git a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj index e2b4c70b6..50b3331ac 100644 --- a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj +++ b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj @@ -103,10 +103,4 @@ - - - ValidationServiceTests.cs - - - diff --git a/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs b/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs deleted file mode 100644 index c5d9f4e13..000000000 --- a/test/Altinn.App.Core.Tests/TestUtils/ServiceProviderTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; - -namespace Altinn.App.Core.Tests.TestUtils; - -public class ServiceProviderTests -{ - private readonly IServiceCollection _serviceProvider = new ServiceCollection(); - - protected class ParentService(IServiceProvider serviceProvider) - { - public SingletonService SingletonService => serviceProvider.GetRequiredService(); - public ScopedService ScopedService => serviceProvider.GetRequiredService(); - } - - protected class ScopedService(IServiceProvider serviceProvider) : ParentService(serviceProvider) { } - - protected class SingletonService(IServiceProvider serviceProvider) : ParentService(serviceProvider) { } - - public ServiceProviderTests() - { - _serviceProvider.AddSingleton(); - _serviceProvider.AddScoped(); - } - - [Fact] - public void TestServices() - { - using var serviceProvider = _serviceProvider.BuildServiceProvider( - new ServiceProviderOptions() { ValidateScopes = true, ValidateOnBuild = true } - ); - // var singletonService = serviceProvider.GetRequiredService(); - // var scopedRootService = singletonService.ScopedService; - - - using var scope = serviceProvider.CreateScope(); - var scopedService = scope.ServiceProvider.GetRequiredService(); - scopedService.Should().NotBeNull(); - scopedService.ScopedService.Should().Be(scopedService); - - using var scope2 = serviceProvider.CreateScope(); - var scopedService2 = scope2.ServiceProvider.GetRequiredService(); - scopedService2.Should().NotBe(scopedService); - scopedService2.ScopedService.Should().Be(scopedService2); - var action = () => scopedService.SingletonService.ScopedService; - action.Should().Throw(); - } -} From 71b129f76643177bf0a8a4a9ac5aded34beec17a Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 23 Sep 2024 08:04:56 +0200 Subject: [PATCH 45/63] Add concept of nonIncrementalValidators Some validators can neither run on every request for efficeincy, nor has a sensible way to implement HasRelevantChanges. Theese validators need to only run on process/next, or when explicitly requested in a call to /validate Also added NoIncrementalUpdates (with [JsonIgnore(WhenWritingDefault)]) to ValidationIssueWithSource, so that FE can distinguish what issues won't be fixed with incremental updates. /validate now supports a boolean to run either only incremental, only non incremnetal or all validators (default) --- .../Controllers/ActionsController.cs | 2 + .../Controllers/DataController.cs | 19 ++--- .../Controllers/ProcessController.cs | 1 + .../Controllers/ValidateController.cs | 7 +- src/Altinn.App.Core/Features/IValidator.cs | 55 ++++++++++++-- .../Telemetry/Telemetry.Validation.cs | 1 - .../Validation/Default/ExpressionValidator.cs | 4 +- ...gacyIInstanceValidatorFormDataValidator.cs | 4 +- .../LegacyIInstanceValidatorTaskValidator.cs | 11 ++- .../Validation/Default/RequiredValidator.cs | 4 +- .../Wrappers/DataElementValidatorWrapper.cs | 14 +++- .../Wrappers/FormDataValidatorWrapper.cs | 9 ++- .../Wrappers/TaskValidatorWrapper.cs | 15 ++-- .../Internal/Patch/DataPatchResult.cs | 18 ++++- .../Internal/Patch/PatchService.cs | 8 +-- .../Internal/Validation/IValidationService.cs | 2 + .../Internal/Validation/ValidationService.cs | 31 ++++++-- .../Validation/ValidationIssueWithSource.cs | 13 +++- .../Controllers/DataController_PatchTests.cs | 2 +- .../Controllers/ValidateControllerTests.cs | 15 ++-- .../ValidateControllerValidateDataTests.cs | 10 ++- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 10 +++ .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 6 ++ .../ValidationServiceOldTests.cs | 6 ++ .../ValidationServiceTests.cs | 7 ++ ...lidatorFunctionForIncremental.verified.txt | 3 +- ...CallsValidatorFunctionForTask.verified.txt | 3 +- .../Validators/ValidationServiceTests.cs | 72 ++++++++++++++++++- .../Internal/Patch/PatchServiceTests.cs | 14 ++-- .../SubForm/SubFormTests.Test1.verified.txt | 33 ++++++--- .../FullTests/SubForm/SubFormTests.cs | 2 +- 31 files changed, 322 insertions(+), 79 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index dc2f37a8e..342f56076 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -222,6 +222,8 @@ await _dataClient.UpdateData( changes.Add( new DataElementChange { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = dataElement, PreviousValue = previousData, CurrentValue = newModel, diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 753310939..34f16e897 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -577,20 +577,23 @@ public async Task> PatchFormDataMultiple if (res.Success) { - foreach (var dataGuid in dataPatchRequest.Patches.Keys) + foreach (var change in res.Ok.ChangedDataElements) { - await UpdateDataValuesOnInstance(instance, dataGuid.ToString(), res.Ok.NewDataModels[dataGuid]); - await UpdatePresentationTextsOnInstance( - instance, - dataGuid.ToString(), - res.Ok.NewDataModels[dataGuid] - ); + if (change.HasAppLogic) + { + await UpdateDataValuesOnInstance(instance, change.DataElement.DataType, change.CurrentValue); + await UpdatePresentationTextsOnInstance( + instance, + change.DataElement.DataType, + change.CurrentValue + ); + } } return Ok( new DataPatchResponseMultiple() { - NewDataModels = res.Ok.NewDataModels, + NewDataModels = res.Ok.GetUpdatedData(), ValidationIssues = res.Ok.ValidationIssues } ); diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index db6d83104..94c5266d9 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -255,6 +255,7 @@ [FromRoute] Guid instanceGuid dataAcceesor, currentTaskId, // run full validation ignoredValidators: null, + onlyIncrementalValidators: null, language: language ); var success = validationIssues.TrueForAll(v => v.Severity != ValidationIssueSeverity.Error); diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index fa2600338..21953167b 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -51,6 +51,7 @@ IAppModel appModel /// Unique id of the party that is the owner of the instance. /// Unique id to identify the instance /// Comma separated list of validators to ignore + /// Ignore validators that don't run on PATCH requests /// The currently used language by the user (or null if not available) [HttpGet] [Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/validate")] @@ -61,6 +62,7 @@ public async Task ValidateInstance( [FromRoute] int instanceOwnerPartyId, [FromRoute] Guid instanceGuid, [FromQuery] string? ignoredValidators = null, + [FromQuery] bool? onlyIncrementalValidators = null, [FromQuery] string? language = null ) { @@ -85,6 +87,7 @@ public async Task ValidateInstance( dataAccessor, taskId, ignoredSources, + onlyIncrementalValidators, language ); return Ok(messages); @@ -160,6 +163,7 @@ public async Task ValidateData( dataAccessor, dataType.TaskId, ignoredValidators: null, + onlyIncrementalValidators: true, language: language ); messages.AddRange(issues.Where(i => i.DataElementId == element.Id)); @@ -178,7 +182,8 @@ public async Task ValidateData( Description = $"Data element for task {dataType.TaskId} validated while currentTask is {taskId}", CustomTextKey = ValidationIssueCodes.DataElementCodes.DataElementValidatedAtWrongTask, CustomTextParams = new List() { dataType.TaskId, taskId }, - Source = GetType().FullName ?? String.Empty + Source = GetType().FullName ?? String.Empty, + NoIncrementalUpdates = true }; messages.Add(message); } diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 279e895d6..157ffbb32 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -20,10 +21,20 @@ public interface IValidator public string ValidationSource => $"{GetType().FullName}-{TaskId}"; /// + /// If true, this validator is costly to run, and should not run on every PATCH request, but only on `/process/next` + /// or when explicit validation is requested. /// + /// Always returning false from has a similar effect, but setting this to false informs + /// frontend that the issues from the validator can't be cached, because FE won't be informed when the issue is fixed. + /// Issues from validators with NoIncrementalValidation will be shown once but prevent process/next from succeding. + /// + bool NoIncrementalValidation => false; + + /// + /// Run this validator and return all the issues this validator is aware of. /// /// The instance to validate - /// Use this to access data from other data elements + /// Use this to access the form data from s /// The current task. /// Language for messages, if the messages are too dynamic for the translation system /// @@ -39,15 +50,15 @@ public Task> Validate( /// This method is used to determine if the validator has relevant changes, or if the cached issues list can be used. /// /// The instance to validate + /// Use this to access data from other data elements /// The current task ID /// List of changed data elements with current and previous value - /// Use this to access data from other data elements /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ); } @@ -56,18 +67,50 @@ IInstanceDataAccessor instanceDataAccessor /// public class DataElementChange { + /// + /// If the data element has app logic you can expect and to be available + /// + [MemberNotNullWhen(true, nameof(CurrentValue), nameof(PreviousValue))] + public required bool HasAppLogic { get; init; } + /// /// The data element the change is related to /// public required DataElement DataElement { get; init; } + /// + /// The type of change that has occurred + /// + public required DataElementChangeType ChangeType { get; init; } + /// /// The state of the data element before the change /// - public required object PreviousValue { get; init; } + public required object? PreviousValue { get; init; } /// /// The state of the data element after the change /// - public required object CurrentValue { get; init; } + public required object? CurrentValue { get; init; } +} + +/// +/// Enum specifying the type of changes that can occur to a data element +/// +public enum DataElementChangeType +{ + /// + /// The data element has appLogic and was updated + /// + Update, + + /// + /// The data element was added + /// + Add, + + /// + /// The data element was removed + /// + Delete, } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs index ff41616b0..4bf708b7f 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Validation.cs @@ -27,7 +27,6 @@ private static void InitValidation(InitContext context) var activity = ActivitySource.StartActivity($"{Prefix}.ValidateIncremental"); activity?.SetTaskId(taskId); // Log the IDs of the elements that have changed together with their data type - // default:123-678-8900-54,group:123-678-8900-55 var changesPrefix = "ChangedDataElements"; var now = DateTimeOffset.UtcNow; diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index db987cc74..cc9b67080 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -53,9 +53,9 @@ IAppMetadata appMetadata /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) => Task.FromResult(true); /// diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index bcea696b6..70c35462d 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -84,9 +84,9 @@ public async Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) { return Task.FromResult(true); diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs index 07364a595..8dc3a8eea 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorTaskValidator.cs @@ -45,6 +45,9 @@ public string ValidationSource } } + /// + public bool NoIncrementalValidation => true; + /// public async Task> Validate( Instance instance, @@ -63,11 +66,13 @@ public async Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) { - return Task.FromResult(false); + throw new NotImplementedException( + "Validators with NoIncrementalValidation should not be used for incremental validation" + ); } } diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index 9d3375514..31223689f 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -52,8 +52,8 @@ public async Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) => Task.FromResult(true); } diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs index 02b6c068c..298689316 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/DataElementValidatorWrapper.cs @@ -29,6 +29,12 @@ List dataTypes /// public string ValidationSource => _dataElementValidator.ValidationSource; + /// + /// The old interface does not support incremental validation. + /// so the issues will only show up when process/next fails + /// + public bool NoIncrementalValidation => true; + /// /// Run all legacy instances for the given . /// @@ -71,12 +77,14 @@ public async Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) { // DataElementValidator did not previously implement incremental validation, so we always return false - return Task.FromResult(false); + throw new NotImplementedException( + "DataElementValidatorWrapper should not be used for incremental validation, because it sets NoIncrementalValidation to true" + ); } } diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index 7b286441f..a63cbf948 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -60,9 +60,9 @@ public async Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) { try @@ -70,7 +70,10 @@ IInstanceDataAccessor instanceDataAccessor foreach (var change in changes) { if ( - (_formDataValidator.DataType == "*" || _formDataValidator.DataType == change.DataElement.DataType) + change.HasAppLogic + && ( + _formDataValidator.DataType == "*" || _formDataValidator.DataType == change.DataElement.DataType + ) && _formDataValidator.HasRelevantChanges(change.CurrentValue, change.PreviousValue) ) { diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs index 9d1c944e7..0a7f6690d 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/TaskValidatorWrapper.cs @@ -24,6 +24,12 @@ public TaskValidatorWrapper(ITaskValidator taskValidator) /// public string ValidationSource => _taskValidator.ValidationSource; + /// + /// The old interface does not support incremental validation. + /// so the issues will only show up when process/next fails + /// + public bool NoIncrementalValidation => true; + /// public Task> Validate( Instance instance, @@ -38,12 +44,13 @@ public Task> Validate( /// public Task HasRelevantChanges( Instance instance, + IInstanceDataAccessor instanceDataAccessor, string taskId, - List changes, - IInstanceDataAccessor instanceDataAccessor + List changes ) { - // TaskValidator did not previously implement incremental validation, so we always return false - return Task.FromResult(false); + throw new NotImplementedException( + "TaskValidatorWrapper should not be used for incremental validation, because it sets NoIncrementalValidation to true" + ); } } diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index bdcc8f34b..fda01b991 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.App.Core.Models.Validation; namespace Altinn.App.Core.Internal.Patch; @@ -15,5 +16,20 @@ public class DataPatchResult /// /// The current data model after the patch operation. /// - public required Dictionary NewDataModels { get; init; } + public required List ChangedDataElements { get; init; } + + /// + /// Get updated data elements that have app logic in a dictionary with the data element id as key. + /// + public Dictionary GetUpdatedData() + { + return ChangedDataElements + .Where(d => d.HasAppLogic) + .ToDictionary( + d => Guid.Parse(d.DataElement.Id), + d => + d.CurrentValue + ?? throw new InvalidOperationException("Data element has app logic but no current value") + ); + } } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index 94a669c79..33cca223b 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -130,6 +130,8 @@ public async Task> ApplyPatches( changes.Add( new DataElementChange { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = dataElement, PreviousValue = oldModel, CurrentValue = newModel, @@ -160,11 +162,7 @@ await _dataClient.UpdateData( language ); - return new DataPatchResult - { - NewDataModels = changes.ToDictionary(c => Guid.Parse(c.DataElement.Id), c => c.CurrentValue), - ValidationIssues = validationIssues - }; + return new DataPatchResult { ChangedDataElements = changes, ValidationIssues = validationIssues }; } private static ServiceResult DeserializeModel(Type type, JsonNode? patchResult) diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index bfcc416af..0531d44e8 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -16,6 +16,7 @@ public interface IValidationService /// Accessor for instance data to be validated /// The task to run validations for (overriding instance.Process?.CurrentTask?.ElementId) /// List of to ignore + /// /// The language to run validations in /// List of validation issues for this data element Task> ValidateInstanceAtTask( @@ -23,6 +24,7 @@ Task> ValidateInstanceAtTask( IInstanceDataAccessor dataAccessor, string taskId, List? ignoredValidators, + bool? onlyIncrementalValidators, string? language ); diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 0903be71b..5ed91d0ae 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -34,6 +34,7 @@ public async Task> ValidateInstanceAtTask( IInstanceDataAccessor dataAccessor, string taskId, List? ignoredValidators, + bool? onlyIncrementalValidators, string? language ) { @@ -42,9 +43,17 @@ public async Task> ValidateInstanceAtTask( using var activity = _telemetry?.StartValidateInstanceAtTaskActivity(taskId); - var validators = _validatorFactory - .GetValidators(taskId) - .Where(v => !(ignoredValidators?.Contains(v.ValidationSource, StringComparer.InvariantCulture)) ?? true); + var validators = _validatorFactory.GetValidators(taskId); + // Filter out validators that should be ignored or not run incrementally + if (onlyIncrementalValidators == true) + validators = validators.Where(v => !v.NoIncrementalValidation); + else if (onlyIncrementalValidators == false) + validators = validators.Where(v => v.NoIncrementalValidation); + if (ignoredValidators is not null) + validators = validators.Where(v => + !ignoredValidators.Contains(v.ValidationSource, StringComparer.InvariantCulture) + ); + // Start the validation tasks (but don't await yet, so that they can run in parallel) var validationTasks = validators.Select(async v => { @@ -55,7 +64,9 @@ public async Task> ValidateInstanceAtTask( validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorIssueCount, issues.Count); return KeyValuePair.Create( v.ValidationSource, - issues.Select(issue => ValidationIssueWithSource.FromIssue(issue, v.ValidationSource)) + issues.Select(issue => + ValidationIssueWithSource.FromIssue(issue, v.ValidationSource, v.NoIncrementalValidation) + ) ); } catch (Exception e) @@ -99,7 +110,7 @@ public async Task>> ValidateI var validators = _validatorFactory .GetValidators(taskId) - .Where(v => !(ignoredValidators?.Contains(v.ValidationSource) ?? false)) + .Where(v => !v.NoIncrementalValidation && !(ignoredValidators?.Contains(v.ValidationSource) ?? false)) .ToArray(); ThrowIfDuplicateValidators(validators, taskId); @@ -110,14 +121,20 @@ public async Task>> ValidateI using var validatorActivity = _telemetry?.StartRunValidatorActivity(validator); try { - var hasRelevantChanges = await validator.HasRelevantChanges(instance, taskId, changes, dataAccessor); + var hasRelevantChanges = await validator.HasRelevantChanges(instance, dataAccessor, taskId, changes); validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorHasRelevantChanges, hasRelevantChanges); if (hasRelevantChanges) { var issues = await validator.Validate(instance, dataAccessor, taskId, language); validatorActivity?.SetTag(Telemetry.InternalLabels.ValidatorIssueCount, issues.Count); var issuesWithSource = issues - .Select(i => ValidationIssueWithSource.FromIssue(i, validator.ValidationSource)) + .Select(i => + ValidationIssueWithSource.FromIssue( + i, + validator.ValidationSource, + validator.NoIncrementalValidation + ) + ) .ToList(); return new KeyValuePair?>( validator.ValidationSource, diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs index 28d2de80a..bb8645269 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Altinn.App.Core.Features; namespace Altinn.App.Core.Models.Validation; @@ -10,7 +11,7 @@ public class ValidationIssueWithSource /// /// Converter function to create a from a and adding a source. /// - public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string source) + public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string source, bool noIncrementalUpdates) { return new ValidationIssueWithSource { @@ -20,6 +21,7 @@ public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string Code = issue.Code, Description = issue.Description, Source = source, + NoIncrementalUpdates = noIncrementalUpdates, CustomTextKey = issue.CustomTextKey, CustomTextParams = issue.CustomTextParams, }; @@ -71,9 +73,17 @@ public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string [JsonPropertyName("source")] public required string Source { get; set; } + /// + /// Weather the issue is from a validator that correctly implements . + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonPropertyName("NoIncrementalUpdates")] + public bool NoIncrementalUpdates { get; set; } + /// /// The custom text key to use for the localized text in the frontend. /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("customTextKey")] public string? CustomTextKey { get; set; } @@ -85,6 +95,7 @@ public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string /// The localized text for the key might be "Date must be between {0} and {1}" /// and the param will provide the dynamical range of allowable dates (eg teh reporting period) /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyName("customTextParams")] public List? CustomTextParams { get; set; } } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 8b5a2bc0a..6952418de 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -310,7 +310,7 @@ public async Task NullName_ReturnsOkAndValidationError() var validationResponse = await client.GetAsync($"/{Org}/{App}/instances/{_instanceId}/validate"); validationResponse.Should().HaveStatusCode(HttpStatusCode.OK); var validationResponseString = await validationResponse.Content.ReadAsStringAsync(); - var validationResponseObject = JsonSerializer.Deserialize>( + var validationResponseObject = JsonSerializer.Deserialize>( validationResponseString, JsonSerializerOptions )!; diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index c718dbb7d..9275ee183 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -139,7 +139,8 @@ public async Task ValidateInstance_returns_OK_with_messages() Description = "dummy", Field = "dummy", Severity = ValidationIssueSeverity.Fixed, - Source = "dummy" + Source = "dummy", + NoIncrementalUpdates = true } }; @@ -148,7 +149,9 @@ public async Task ValidateInstance_returns_OK_with_messages() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) + .Setup(v => + v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null, null) + ) .ReturnsAsync(validationResult); // Act @@ -186,7 +189,9 @@ public async Task ValidateInstance_returns_403_when_not_authorized() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) + .Setup(v => + v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null, null) + ) .Throws(exception); // Act @@ -224,7 +229,9 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() .Returns(Task.FromResult(instance)); _validationMock - .Setup(v => v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null)) + .Setup(v => + v.ValidateInstanceAtTask(instance, It.IsAny(), "dummy", null, null, null) + ) .Throws(exception); // Act diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 30dd0186e..44aecce33 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -98,7 +98,8 @@ public class TestScenariosData : IEnumerable null, LanguageConst.Nb ), - Source = "source" + Source = "source", + NoIncrementalUpdates = true }, }, ExpectedResult = typeof(OkObjectResult) @@ -140,7 +141,8 @@ public class TestScenariosData : IEnumerable Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, Description = "dummy", Severity = ValidationIssueSeverity.Fixed, - Source = "source" + Source = "source", + NoIncrementalUpdates = true }, }, ExpectedValidationIssues = new List @@ -150,7 +152,8 @@ public class TestScenariosData : IEnumerable Code = ValidationIssueCodes.DataElementCodes.DataElementTooLarge, Description = "dummy", Severity = ValidationIssueSeverity.Fixed, - Source = "source" + Source = "source", + NoIncrementalUpdates = true } }, ExpectedResult = typeof(OkObjectResult) @@ -254,6 +257,7 @@ private void SetupMocks(string app, string org, int instanceOwnerId, ValidateDat It.IsAny(), "Task_1", null, + It.IsAny(), null ) ) diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 1c5a8a85b..517748e96 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -4809,6 +4809,13 @@ "type": "string" } }, + { + "name": "onlyIncrementalValidators", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "language", "in": "query", @@ -7035,6 +7042,9 @@ "type": "string", "nullable": true }, + "NoIncrementalUpdates": { + "type": "boolean" + }, "customTextKey": { "type": "string", "nullable": true diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index e64029def..49b0c3ed5 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -2928,6 +2928,10 @@ paths: in: query schema: type: string + - name: onlyIncrementalValidators + in: query + schema: + type: boolean - name: language in: query schema: @@ -4529,6 +4533,8 @@ components: source: type: string nullable: true + NoIncrementalUpdates: + type: boolean customTextKey: type: string nullable: true diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs index a7e45c1bc..08e9fa6a4 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceOldTests.cs @@ -79,6 +79,7 @@ public async Task FileScanEnabled_VirusFound_ValidationShouldFail() dataAccessor.Object, "Task_1", null, + null, null ); @@ -108,6 +109,7 @@ public async Task FileScanEnabled_PendingScanNotEnabled_ValidationShouldNotFail( dataAccessorMock.Object, "Task_1", null, + null, null ); @@ -137,6 +139,7 @@ public async Task FileScanEnabled_PendingScanEnabled_ValidationShouldNotFail() dataAccessorMock.Object, "Task_1", null, + null, null ); @@ -165,6 +168,7 @@ public async Task FileScanEnabled_Clean_ValidationShouldNotFail() dataAccessorMock.Object, "Task_1", null, + null, null ); @@ -211,6 +215,7 @@ public async Task ValidateAndUpdateProcess_set_canComplete_validationstatus_and_ dataAccessorMock.Object, taskId, null, + null, null ); issues.Should().BeEmpty(); @@ -269,6 +274,7 @@ public async Task ValidateAndUpdateProcess_set_canComplete_false_validationstatu dataAccessorMock.Object, taskId, null, + null, null ); issues.Should().HaveCount(1); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index 297ff1e65..ae2f66b99 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -274,6 +274,7 @@ public async Task Validate_WithNoValidators_ReturnsNoErrors() _dataAccessor, DefaultTaskId, null, + null, DefaultLanguage ); resultTask.Should().BeEmpty(); @@ -320,6 +321,8 @@ public async Task ValidateFormData_WithSpecificValidator() { new() { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = _defaultDataElement, PreviousValue = previousData, CurrentValue = data @@ -373,6 +376,8 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa [ new DataElementChange() { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = _defaultDataElement, CurrentValue = data, PreviousValue = data, @@ -457,6 +462,7 @@ List CreateIssues(string code) dataAccessor, DefaultTaskId, null, + null, DefaultLanguage ); @@ -520,6 +526,7 @@ public async Task ValidateTask_ReturnsNoErrorsFromAllLevels() _dataAccessor, DefaultTaskId, null, + null, DefaultLanguage ); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt index e280b0128..076347da0 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt @@ -58,7 +58,8 @@ DataElementId: DataElementId_0, Code: TestCode, Description: Test error, - Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default, + NoIncrementalUpdates: false } ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt index 9ee2d83fb..9185cf440 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForTask.verified.txt @@ -37,7 +37,8 @@ DataElementId: DataElementId_0, Code: TestCode, Description: Test error, - Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default, + NoIncrementalUpdates: false } ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 445cb9570..11df633eb 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -59,14 +59,15 @@ private Mock RegistrerValidatorMock( bool? hasRelevantChanges = null, List? expectedChanges = null, List? issues = null, - string? expectedLanguage = null + string? expectedLanguage = null, + bool noIncrementalValidation = false ) { var mock = new Mock(MockBehavior.Strict); mock.Setup(v => v.ValidationSource).Returns(source); if (hasRelevantChanges.HasValue && expectedChanges is not null) { - mock.Setup(v => v.HasRelevantChanges(_instance, "Task_1", expectedChanges, _instanceDataAccessor)) + mock.Setup(v => v.HasRelevantChanges(_instance, _instanceDataAccessor, "Task_1", expectedChanges)) .ReturnsAsync(hasRelevantChanges.Value); } @@ -76,6 +77,8 @@ private Mock RegistrerValidatorMock( .ReturnsAsync(issues); } + mock.SetupGet(v => v.NoIncrementalValidation).Returns(noIncrementalValidation); + _services.AddSingleton(mock.Object); return mock; } @@ -92,6 +95,7 @@ public async Task ValidateInstanceAtTask_WithNoData_ShouldReturnNoIssues() _instanceDataAccessor, "Task_1", null, + null, null ); @@ -167,6 +171,7 @@ public async Task ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonI _instanceDataAccessor, "Task_1", new List { "IgnoredValidator" }, + null, language ); var issueWithSource = result.Should().ContainSingle().Which; @@ -177,6 +182,64 @@ public async Task ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonI issueWithSource.DataElementId.Should().BeNull(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ValidateInstanceAtTask_WithDifferentValidators_ShouldIgnoreNonIncrementalValidatorsWhenSpecified( + bool? onlyIncrementalValidators + ) + { + var incrementalMock = RegistrerValidatorMock( + source: "Validator", + hasRelevantChanges: null, + issues: [], + noIncrementalValidation: false + ); + var nonIncrementalMock = RegistrerValidatorMock( + source: "NonIncrementalValidator", + hasRelevantChanges: null, + issues: [], + noIncrementalValidation: true + ); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var issues = await validationService.ValidateInstanceAtTask( + _instance, + _instanceDataAccessor, + "Task_1", + null, + onlyIncrementalValidators, + null + ); + + issues.Should().BeEmpty(); + + switch (onlyIncrementalValidators) + { + case true: + incrementalMock.Verify(v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), Times.Once); + nonIncrementalMock.Verify( + v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), + Times.Never + ); + break; + case false: + incrementalMock.Verify(v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), Times.Never); + nonIncrementalMock.Verify( + v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), + Times.Once + ); + break; + case null: + incrementalMock.Verify(v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), Times.Once); + nonIncrementalMock.Verify( + v => v.Validate(_instance, _instanceDataAccessor, "Task_1", null), + Times.Once + ); + break; + } + } + private class GenericValidatorFake : GenericFormDataValidator { private readonly IEnumerable _issues; @@ -243,6 +306,7 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu _instanceDataAccessor, taskId, null, + null, null ); var issue = issues.Should().ContainSingle().Which; @@ -295,12 +359,16 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu { new DataElementChange() { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = dataElement, CurrentValue = "currentValue", PreviousValue = "previousValue" }, new DataElementChange() { + HasAppLogic = true, + ChangeType = DataElementChangeType.Update, DataElement = dataElementNoValidation, CurrentValue = "currentValue", PreviousValue = "previousValue" diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index a3a5d6fdd..0b270c547 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -175,12 +175,14 @@ public async Task Test_Ok() response.Success.Should().BeTrue(); response.Ok.Should().NotBeNull(); var res = response.Ok!; - res.NewDataModels.Should() - .ContainKey(_dataGuid) - .WhoseValue.Should() - .BeOfType() - .Subject.Name.Should() - .Be("Test Testesen"); + var change = res + .ChangedDataElements.Should() + .ContainSingle() + .Which.Should() + .BeOfType() + .Which; + change.DataElement.Id.Should().Be(_dataGuid.ToString()); + change.CurrentValue.Should().BeOfType().Subject.Name.Should().Be("Test Testesen"); var validator = res.ValidationIssues.Should().ContainSingle().Which; validator.Key.Should().Be("formDataValidator"); var issue = validator.Value.Should().ContainSingle().Which; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt index c1c8132c4..8c75f138f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.Test1.verified.txt @@ -5,7 +5,8 @@ Field: Address, Code: The field Address must be a string or array type with a minimum length of '44'., Description: The field Address must be a string or array type with a minimum length of '44'., - Source: DataAnnotations + Source: DataAnnotations, + NoIncrementalUpdates: false }, { Severity: Error, @@ -13,7 +14,8 @@ Field: Address, Code: required, Description: Address is required in component with id subFormLayout.SubPage.Address for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -21,7 +23,8 @@ Field: Name, Code: required, Description: Name is required in component with id subFormLayout.SubPage.Name for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -29,7 +32,8 @@ Field: Name, Code: The Name field is required., Description: The Name field is required., - Source: DataAnnotations + Source: DataAnnotations, + NoIncrementalUpdates: false }, { Severity: Error, @@ -37,7 +41,8 @@ Field: Phone, Code: required, Description: Phone is required in component with id subFormLayout.SubPage.Phone for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -45,7 +50,8 @@ Field: Email, Code: required, Description: Email is required in component with id subFormLayout.SubPage.Email for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -53,7 +59,8 @@ Field: Phone, Code: The field Phone must match the regular expression '^\+47\d+'., Description: The field Phone must match the regular expression '^\+47\d+'., - Source: DataAnnotations + Source: DataAnnotations, + NoIncrementalUpdates: false }, { Severity: Error, @@ -61,7 +68,8 @@ Field: Address, Code: required, Description: Address is required in component with id subFormLayout.SubPage.Address for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -69,7 +77,8 @@ Field: Name, Code: required, Description: Name is required in component with id subFormLayout.SubPage.Name for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false }, { Severity: Error, @@ -77,7 +86,8 @@ Field: Name, Code: The Name field is required., Description: The Name field is required., - Source: DataAnnotations + Source: DataAnnotations, + NoIncrementalUpdates: false }, { Severity: Error, @@ -85,6 +95,7 @@ Field: Phone, Code: required, Description: Phone is required in component with id subFormLayout.SubPage.Phone for binding simpleBinding, - Source: Required + Source: Required, + NoIncrementalUpdates: false } ] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs index bfb0b741c..2689cbd64 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -265,7 +265,7 @@ public async Task Test1() { _instance.Data[3], new SubFormModel(null, null, null, null, null) } }; - var issues = await validationService.ValidateInstanceAtTask(_instance, dataAccessor, TaskId, null, null); + var issues = await validationService.ValidateInstanceAtTask(_instance, dataAccessor, TaskId, null, null, null); _output.WriteLine(JsonSerializer.Serialize(issues, _options)); // Order of issues is not guaranteed, so we sort them before verification From f3ebc39c1ca4d1555b747105abf4b415f486dbed Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sun, 29 Sep 2024 23:47:11 +0200 Subject: [PATCH 46/63] Add ModelSerializationService and other cleanup as a prerequisite to new dataProcessor --- .../Controllers/ActionsController.cs | 18 +- .../Controllers/DataController.cs | 18 +- .../Controllers/ProcessController.cs | 12 +- .../Controllers/ValidateController.cs | 12 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Features/IInstanceDataAccessor.cs | 17 +- src/Altinn.App.Core/Features/IValidator.cs | 40 +-- .../Telemetry/Telemetry.ModelSerialization.cs | 34 +++ ...gacyIInstanceValidatorFormDataValidator.cs | 7 +- .../Wrappers/FormDataValidatorWrapper.cs | 9 +- .../Helpers/DataModel/DataModel.cs | 8 +- src/Altinn.App.Core/Helpers/MemoryAsStream.cs | 57 ++++ src/Altinn.App.Core/Helpers/ObjectUtils.cs | 5 +- .../Helpers/RemoveBomExtentions.cs | 31 +- .../Serialization/ModelDeserializer.cs | 3 + .../ModelSerializationService.cs | 267 ++++++++++++++++++ .../Clients/Storage/DataClient.cs | 125 ++++---- .../Data/CachedInstanceDataAccessor.cs | 233 ++++++++++----- .../Internal/Data/IDataClient.cs | 31 +- .../LayoutEvaluatorStateInitializer.cs | 17 +- .../Internal/Patch/DataPatchResult.cs | 12 +- .../Internal/Patch/PatchService.cs | 118 +++++--- .../Internal/Process/ProcessNavigator.cs | 10 +- .../Common/ProcessTaskFinalizer.cs | 43 ++- .../Internal/Validation/IValidationService.cs | 2 +- src/Altinn.App.Core/Models/DataElementId.cs | 27 +- .../Validation/ValidationIssueWithSource.cs | 2 +- ...alidTestValue_ReturnsConflict.verified.txt | 9 + .../Controllers/DataController_PatchTests.cs | 12 +- ...dator_ReturnsValidationErrors.verified.txt | 9 + ...sNext_PdfFails_DataIsUnlocked.verified.txt | 18 ++ .../Controllers/ValidateControllerTests.cs | 60 ++-- .../ValidateControllerValidateDataTests.cs | 3 +- .../Mocks/DataClientMock.cs | 199 ++++++------- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 2 +- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 2 +- .../Default/LegacyIValidationFormDataTests.cs | 17 +- .../ValidationServiceTests.cs | 52 ++-- .../Validators/ValidationServiceTests.cs | 12 +- .../Helpers/MemoryAsStreamTests.cs | 114 ++++++++ .../ObjectUtils_XmlSerializationTests.cs | 22 +- .../Clients/Storage/DataClientTests.cs | 12 +- .../Clients/Storage/TestData/ExampleModel.cs | 8 + .../Internal/Patch/PatchServiceTests.cs | 77 ++--- .../ExpressionsExclusiveGatewayTests.cs | 11 +- .../Internal/Process/ProcessNavigatorTests.cs | 3 +- .../Common/ProcessTaskFinalizerTests.cs | 3 +- .../TestUtilities/InstanceDataAccessorFake.cs | 11 +- 48 files changed, 1257 insertions(+), 559 deletions(-) create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs create mode 100644 src/Altinn.App.Core/Helpers/MemoryAsStream.cs create mode 100644 src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs create mode 100644 test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index 342f56076..64c2b56de 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -4,6 +4,7 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -35,6 +36,7 @@ public class ActionsController : ControllerBase private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; private readonly IAppModel _appModel; + private readonly ModelSerializationService _modelSerialization; /// /// Create new instance of the class @@ -46,7 +48,8 @@ public ActionsController( IValidationService validationService, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel + IAppModel appModel, + ModelSerializationService modelSerialization ) { _authorization = authorization; @@ -56,6 +59,7 @@ IAppModel appModel _dataClient = dataClient; _appMetadata = appMetadata; _appModel = appModel; + _modelSerialization = modelSerialization; } /// @@ -160,7 +164,7 @@ public async Task> Perform( ); } - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); Dictionary>>? validationIssues = null; if (result.UpdatedDataModels is { Count: > 0 }) @@ -202,7 +206,7 @@ Dictionary resultUpdatedDataModels continue; } var dataElement = instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); - var previousData = await dataAccessor.GetData(dataElement); + var previousData = await dataAccessor.GetFormData(dataElement); ObjectUtils.InitializeAltinnRowId(newModel); ObjectUtils.PrepareModelForXmlStorage(newModel); @@ -217,16 +221,14 @@ await _dataClient.UpdateData( Guid.Parse(dataElement.Id) ); // update dataAccessor to use the changed data - dataAccessor.Set(dataElement, newModel); + dataAccessor.SetFormData(dataElement, newModel); // add change to list changes.Add( new DataElementChange { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, DataElement = dataElement, - PreviousValue = previousData, - CurrentValue = newModel, + PreviousFormData = previousData, + CurrentFormData = newModel, } ); } diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 34f16e897..eb8dcd7b6 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -577,23 +577,21 @@ public async Task> PatchFormDataMultiple if (res.Success) { + // TODO: handle added and deleted data elements foreach (var change in res.Ok.ChangedDataElements) { - if (change.HasAppLogic) - { - await UpdateDataValuesOnInstance(instance, change.DataElement.DataType, change.CurrentValue); - await UpdatePresentationTextsOnInstance( - instance, - change.DataElement.DataType, - change.CurrentValue - ); - } + await UpdateDataValuesOnInstance(instance, change.DataElement.DataType, change.CurrentFormData); + await UpdatePresentationTextsOnInstance( + instance, + change.DataElement.DataType, + change.CurrentFormData + ); } return Ok( new DataPatchResponseMultiple() { - NewDataModels = res.Ok.GetUpdatedData(), + NewDataModels = res.Ok.UpdatedData, ValidationIssues = res.Ok.ValidationIssues } ); diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 94c5266d9..7c7bdcc0d 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -4,8 +4,8 @@ using Altinn.App.Api.Models; using Altinn.App.Core.Constants; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; @@ -43,7 +43,7 @@ public class ProcessController : ControllerBase private readonly IProcessReader _processReader; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; - private readonly IAppModel _appModel; + private readonly ModelSerializationService _modelSerialization; /// /// Initializes a new instance of the @@ -58,7 +58,7 @@ public ProcessController( IProcessEngine processEngine, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel + ModelSerializationService modelSerialization ) { _logger = logger; @@ -70,7 +70,7 @@ IAppModel appModel _processEngine = processEngine; _dataClient = dataClient; _appMetadata = appMetadata; - _appModel = appModel; + _modelSerialization = modelSerialization; } /// @@ -249,10 +249,10 @@ [FromRoute] Guid instanceGuid string? language ) { - var dataAcceesor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); var validationIssues = await _validationService.ValidateInstanceAtTask( instance, - dataAcceesor, + dataAccessor, currentTaskId, // run full validation ignoredValidators: null, onlyIncrementalValidators: null, diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index 21953167b..f32a96d76 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -1,6 +1,6 @@ using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; @@ -20,7 +20,7 @@ public class ValidateController : ControllerBase { private readonly IInstanceClient _instanceClient; private readonly IDataClient _dataClient; - private readonly IAppModel _appModel; + private readonly ModelSerializationService _modelSerialization; private readonly IAppMetadata _appMetadata; private readonly IValidationService _validationService; @@ -32,14 +32,14 @@ public ValidateController( IValidationService validationService, IAppMetadata appMetadata, IDataClient dataClient, - IAppModel appModel + ModelSerializationService modelSerialization ) { _instanceClient = instanceClient; _validationService = validationService; _appMetadata = appMetadata; _dataClient = dataClient; - _appModel = appModel; + _modelSerialization = modelSerialization; } /// @@ -80,7 +80,7 @@ public async Task ValidateInstance( try { - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); var ignoredSources = ignoredValidators?.Split(',').ToList(); List messages = await _validationService.ValidateInstanceAtTask( instance, @@ -155,7 +155,7 @@ public async Task ValidateData( throw new ValidationException("Unknown element type."); } - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); // Run validations for all data elements, but only return the issues for the specific data element var issues = await _validationService.ValidateInstanceAtTask( diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index cbe1bf929..1f0549751 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ using Altinn.App.Core.Features.Pdf; using Altinn.App.Core.Features.Validation; using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Implementation; using Altinn.App.Core.Infrastructure.Clients.Authentication; using Altinn.App.Core.Infrastructure.Clients.Authorization; @@ -179,6 +180,7 @@ IWebHostEnvironment env services.Configure(configuration.GetSection("AccessTokenSettings")); services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); + services.AddSingleton(); AddAppOptions(services); AddExternalApis(services); diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index e5757f69f..ecbeb20c1 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -16,11 +16,20 @@ public interface IInstanceDataAccessor /// /// Get the actual data represented in the data element. /// - /// The deserialized data model for this data element or a stream for binary elements - Task GetData(DataElementId dataElementId); + /// The deserialized data model for this data element or an exception for non-form data elements + Task GetFormData(DataElementId dataElementId); /// - /// Get actual data represented, when there should only be a single element of this type. + /// Gets the raw binary data from a DataElement. + /// ´ + /// Form data elements (with appLogic) will get json serialized UTF-8 + /// Throws an InvalidOperationException if the data element is not found on the instance + /// + Task> GetBinaryData(DataElementId dataElementId); + + /// + /// Get a data element from an instance by id, /// - Task GetSingleDataByType(string dataType); + /// Throws an InvalidOperationException if the data element is not found on the instance + DataElement GetDataElement(DataElementId dataElementId); } diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 157ffbb32..7cfbdc696 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -67,50 +67,18 @@ List changes /// public class DataElementChange { - /// - /// If the data element has app logic you can expect and to be available - /// - [MemberNotNullWhen(true, nameof(CurrentValue), nameof(PreviousValue))] - public required bool HasAppLogic { get; init; } - /// /// The data element the change is related to /// - public required DataElement DataElement { get; init; } - - /// - /// The type of change that has occurred - /// - public required DataElementChangeType ChangeType { get; init; } + public required DataElementId DataElement { get; init; } /// /// The state of the data element before the change /// - public required object? PreviousValue { get; init; } + public required object PreviousFormData { get; init; } /// /// The state of the data element after the change /// - public required object? CurrentValue { get; init; } -} - -/// -/// Enum specifying the type of changes that can occur to a data element -/// -public enum DataElementChangeType -{ - /// - /// The data element has appLogic and was updated - /// - Update, - - /// - /// The data element was added - /// - Add, - - /// - /// The data element was removed - /// - Delete, + public required object CurrentFormData { get; init; } } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs new file mode 100644 index 000000000..d77eb0ca1 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + internal Activity? StartSerializeToXmlActivity(Type typeToSerialize) + { + var activity = ActivitySource.StartActivity("SerializationService.SerializeXml"); + activity?.SetTag("Type", typeToSerialize.FullName); + return activity; + } + + internal Activity? StartSerializeToJsonActivity(Type typeToSerialize) + { + var activity = ActivitySource.StartActivity("SerializationService.SerializeXml"); + activity?.SetTag("Type", typeToSerialize.FullName); + return activity; + } + + internal Activity? StartDeserializeFromXmlActivity(Type typeToDeserialize) + { + var activity = ActivitySource.StartActivity("SerializationService.DeserializeXml"); + activity?.SetTag("Type", typeToDeserialize.FullName); + return activity; + } + + internal Activity? StartDeserializeFromJsonActivity(Type typeToDeserialize) + { + var activity = ActivitySource.StartActivity("SerializationService.DeserializeJson"); + activity?.SetTag("Type", typeToDeserialize.FullName); + return activity; + } +} diff --git a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs index 70c35462d..310612186 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/LegacyIInstanceValidatorFormDataValidator.cs @@ -59,10 +59,13 @@ public async Task> Validate( { var issues = new List(); var appMetadata = await _appMetadata.GetApplicationMetadata(); - var dataTypes = appMetadata.DataTypes.Where(d => d.TaskId == taskId).Select(d => d.Id).ToList(); + var dataTypes = appMetadata + .DataTypes.Where(d => d.TaskId == taskId && d.AppLogic?.ClassRef != null) + .Select(d => d.Id) + .ToList(); foreach (var dataElement in instance.Data.Where(d => dataTypes.Contains(d.DataType))) { - var data = await instanceDataAccessor.GetData(dataElement); + var data = await instanceDataAccessor.GetFormData(dataElement); var modelState = new ModelStateDictionary(); await _instanceValidator.ValidateData(data, modelState); issues.AddRange( diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index a63cbf948..177b094c0 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -42,7 +42,7 @@ public async Task> Validate( continue; } - var data = await instanceDataAccessor.GetData(dataElement); + var data = await instanceDataAccessor.GetFormData(dataElement); var dataElementValidationResult = await _formDataValidator.ValidateFormData( instance, dataElement, @@ -70,11 +70,8 @@ List changes foreach (var change in changes) { if ( - change.HasAppLogic - && ( - _formDataValidator.DataType == "*" || _formDataValidator.DataType == change.DataElement.DataType - ) - && _formDataValidator.HasRelevantChanges(change.CurrentValue, change.PreviousValue) + (_formDataValidator.DataType == "*" || _formDataValidator.DataType == change.DataElement.DataType) + && _formDataValidator.HasRelevantChanges(change.CurrentFormData, change.PreviousFormData) ) { return Task.FromResult(true); diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index d308f7d04..fe686094a 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -52,7 +52,7 @@ DataElementId defaultDataElementId { if (key.DataType == null) { - return (defaultDataElementId, await _dataAccessor.GetData(defaultDataElementId)); + return (defaultDataElementId, await _dataAccessor.GetFormData(defaultDataElementId)); } if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) @@ -63,7 +63,7 @@ DataElementId defaultDataElementId $"{key.DataType} has maxCount different from 1 in applicationmetadata.json or don't have a classRef in appLogic" ); } - return (dataElementId.Value, await _dataAccessor.GetData(dataElementId.Value)); + return (dataElementId.Value, await _dataAccessor.GetFormData(dataElementId.Value)); } throw new InvalidOperationException( @@ -109,7 +109,7 @@ DataElementId defaultDataElementId /// public async Task GetResolvedKeys(DataReference reference) { - var model = await _dataAccessor.GetData(reference.DataElementId); + var model = await _dataAccessor.GetFormData(reference.DataElementId); var modelWrapper = new DataModelWrapper(model); return modelWrapper .GetResolvedKeys(reference.Field) @@ -156,7 +156,7 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa /// public async Task RemoveField(DataReference reference, RowRemovalOption rowRemovalOption) { - var serviceModel = await _dataAccessor.GetData(reference.DataElementId); + var serviceModel = await _dataAccessor.GetFormData(reference.DataElementId); if (serviceModel is null) { throw new DataModelException( diff --git a/src/Altinn.App.Core/Helpers/MemoryAsStream.cs b/src/Altinn.App.Core/Helpers/MemoryAsStream.cs new file mode 100644 index 000000000..01a266071 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/MemoryAsStream.cs @@ -0,0 +1,57 @@ +namespace Altinn.App.Core.Helpers; + +internal class MemoryAsStream : Stream +{ + private readonly ReadOnlyMemory _memory; + + public MemoryAsStream(ReadOnlyMemory memory) + { + _memory = memory; + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) + { + count = (int)Math.Min(count, Length - Position); + if (count > 0) + { + var toCopy = _memory.Span.Slice((int)Position, count); + toCopy.CopyTo(buffer.AsSpan(offset)); + Position += count; + return count; + } + + return 0; + } + + public override long Seek(long offset, SeekOrigin origin) + { + Position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Position + offset, + SeekOrigin.End => Length + offset, // Assume offset is negative + _ => throw new ArgumentOutOfRangeException(nameof(origin), origin, "SeekOrigin not supported") + }; + // Validate position? + + return Position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override bool CanRead => true; + public override bool CanSeek => true; + public override bool CanWrite => false; + public override long Length => _memory.Length; + public override long Position { get; set; } +} diff --git a/src/Altinn.App.Core/Helpers/ObjectUtils.cs b/src/Altinn.App.Core/Helpers/ObjectUtils.cs index a440dc874..e7f0f5e40 100644 --- a/src/Altinn.App.Core/Helpers/ObjectUtils.cs +++ b/src/Altinn.App.Core/Helpers/ObjectUtils.cs @@ -73,10 +73,13 @@ public static void InitializeAltinnRowId(object model, int depth = 64) /// /// Xml serialization-deserialization does not preserve all properties, and we sometimes need /// to know how it looks when it comes back from storage. + /// + /// /// * Recursively initialize all properties on the object that are currently null /// * Ensure that all string properties with `[XmlTextAttribute]` that are empty or whitespace are set to null /// * If a class has `[XmlTextAttribute]` and no value, set the parent property to null (if the other properties has [BindNever] attribute) - /// + /// * If a property has a `ShouldSerialize{PropertyName}` method that returns false, set the property to default value + /// /// The object to mutate /// Remaining recursion depth. To prevent infinite recursion we stop prepeation after this depth. (default matches json serialization) public static void PrepareModelForXmlStorage(object model, int depth = 64) diff --git a/src/Altinn.App.Core/Helpers/RemoveBomExtentions.cs b/src/Altinn.App.Core/Helpers/RemoveBomExtentions.cs index b2c96aafe..cd512c3dc 100644 --- a/src/Altinn.App.Core/Helpers/RemoveBomExtentions.cs +++ b/src/Altinn.App.Core/Helpers/RemoveBomExtentions.cs @@ -5,11 +5,38 @@ internal static class RemoveBomExtentions private static readonly byte[] _utf8bom = [0xEF, 0xBB, 0xBF]; internal static ReadOnlySpan RemoveBom(this byte[] bytes) + { + return RemoveBom((ReadOnlySpan)bytes); + } + + internal static ReadOnlySpan RemoveBom(this ReadOnlySpan bytes) + { + // Remove UTF8 BOM (if present) + if (bytes.StartsWith(_utf8bom)) + { + return bytes.Slice(_utf8bom.Length); + } + + return bytes; + } + + internal static ReadOnlyMemory RemoveBom(this ReadOnlyMemory bytes) + { + // Remove UTF8 BOM (if present) + if (bytes.Span.StartsWith(_utf8bom)) + { + return bytes.Slice(_utf8bom.Length); + } + + return bytes; + } + + internal static Memory RemoveBom(this Memory bytes) { // Remove UTF8 BOM (if present) - if (bytes.AsSpan().StartsWith(_utf8bom)) + if (bytes.Span.StartsWith(_utf8bom)) { - return bytes.AsSpan().Slice(_utf8bom.Length); + return bytes.Slice(_utf8bom.Length); } return bytes; diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs index f095fb469..3210effa5 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs @@ -9,6 +9,9 @@ namespace Altinn.App.Core.Helpers.Serialization; /// /// Represents logic to deserialize a stream of data to an instance of the given type /// +// [Obsolete( +// "This class is deprecated and will be removed in a v9. Use Altinn.App.PlatformServices.Helpers.Serialization.ModelSerializationSerivce from dependency injection instead." +// )] public class ModelDeserializer { private static readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs new file mode 100644 index 000000000..a6408f665 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs @@ -0,0 +1,267 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Serialization; +using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.AppModel; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.App.Core.Helpers.Serialization; + +/// +/// DI registered service for centralizing (de)serialization logic for data models +/// +public class ModelSerializationService +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); + private static readonly XmlSerializerCache _xmlSerializer = new(); + + private readonly Telemetry? _telemetry; + private readonly IAppModel _appModel; + + /// + /// Constructor for the ModelDeserializerService + /// + public ModelSerializationService(IAppModel appModel, Telemetry? telemetry = null) + { + _appModel = appModel; + _telemetry = telemetry; + } + + /// + /// Deserialize binary data from storage to a model of the classRef specified in the dataType + /// + /// The binary data + /// The data type used to get content type and the classRef for the object to be returned + /// The model specified in + public object DeserializeFromStorage(ReadOnlySpan data, DataType dataType) + { + var type = GetModelTypeForDataType(dataType); + + // TODO: support sending json to storage based on dataType.ContentTypes + return DeserializeXml(data, type); + } + + /// + /// Serialize an object to binary data for storage, respecting classRef and content type in dataType + /// + /// The object to serialize (must match the classRef in DataType) + /// The data type + /// the binary data and the content type (currently only application/xml, but likely also json in the future) + /// If the classRef in dataType does not match type of the model + public (ReadOnlyMemory data, string contentType) SerializeToStorage(object model, DataType dataType) + { + var type = GetModelTypeForDataType(dataType); + if (type != model.GetType()) + { + throw new InvalidOperationException( + $"DataType {dataType.Id} expects {type.FullName}, found {model.GetType().FullName}" + ); + } + + //TODO: support sending json to storage based on dataType.ContentTypes + return (SerializeToXml(model), "application/xml"); + } + + /// + /// Serialize an object to xml + /// + /// The object to serialize + /// The bytes of the serialized xml in UTF8 encoding + public ReadOnlyMemory SerializeToXml(object model) + { + var modelType = model.GetType(); + using var activity = _telemetry?.StartSerializeToXmlActivity(modelType); + + // Ensure that model is mutated in the same way it would be when deserialized from storage + // (evaluate ShouldSerialize* methods, set empty strings to null, etc.) + ObjectUtils.PrepareModelForXmlStorage(model); + + XmlWriterSettings xmlWriterSettings = new XmlWriterSettings() + { + Encoding = new UTF8Encoding(false), + NewLineHandling = NewLineHandling.None, + }; + using var memoryStream = new MemoryStream(); + XmlWriter xmlWriter = XmlWriter.Create(memoryStream, xmlWriterSettings); + + XmlSerializer serializer = _xmlSerializer.GetSerializer(modelType); + serializer.Serialize(xmlWriter, model); + if (!memoryStream.TryGetBuffer(out var segment)) + { + throw new InvalidOperationException("Failed to get buffer from memory stream"); + } + + return segment.AsMemory().RemoveBom(); + } + + /// + /// Serialize an object to json + /// + /// The object to serialize + /// the serialized UTF8 encoded bytes + public ReadOnlyMemory SerializeToJson(object model) + { + var modelType = model.GetType(); + using var span = _telemetry?.StartSerializeToJsonActivity(modelType); + var json = JsonSerializer.SerializeToUtf8Bytes(model, modelType, _jsonSerializerOptions); + return json; + } + + // public async Task> DeserializeSingleFromRequest( + // Stream body, + // string? contentType, + // DataType dataType + // ) + // { + // using var memoryStream = new MemoryStream(); + // await body.CopyToAsync(memoryStream); + // if (!memoryStream.TryGetBuffer(out var segment)) + // { + // throw new InvalidOperationException("Failed to get buffer from memory stream"); + // } + // + // var modelType = GetModelTypeForDataType(dataType); + // object model; + // if (contentType?.Contains("application/xml") ?? true) // default to xml if no content type is provided + // { + // model = DeserializeXml(segment, modelType); + // } + // else if (contentType.Contains("application/json")) + // { + // model = DeserializeJson(segment, modelType); + // } + // else + // { + // return new ProblemDetails() + // { + // Title = "Unsupported content type", + // Detail = $"Content type {contentType} is not supported for deserialization", + // Status = StatusCodes.Status415UnsupportedMediaType, + // }; + // } + // + // return model; + // } + + /// + /// Deserialize utf8 encoded json data to a model of the specified type + /// + /// Basically just JsonSerializer.Deserialize, but with a telemetry activity and BOM removal + /// + /// The binary UTF8 encoded json + /// The target type for deserialization + /// The deserialized object + public object DeserializeJson(ReadOnlySpan data, Type modelType) + { + using var activity = _telemetry?.StartDeserializeFromJsonActivity(modelType); + return JsonSerializer.Deserialize(data.RemoveBom(), modelType, _jsonSerializerOptions) + ?? throw new JsonException("Json deserialization returned null"); + } + + /// + /// + /// + /// + /// + /// + public object DeserializeXml(ReadOnlySpan data, Type modelType) + { + using var activity = _telemetry?.StartDeserializeFromXmlActivity(modelType); + // convert to UTF16 string as it seems to be preferred by the XmlTextReader + // and aligns with previous implementation + string streamContent = Encoding.UTF8.GetString(data.RemoveBom()); + if (string.IsNullOrWhiteSpace(streamContent)) + { + throw new Exception("No XML content read from stream"); + } + try + { + using XmlTextReader xmlTextReader = new XmlTextReader(new StringReader(streamContent)); + XmlSerializer serializer = _xmlSerializer.GetSerializer(modelType); + + return serializer.Deserialize(xmlTextReader) + ?? throw new InvalidOperationException("Deserialization returned null"); + } + catch (InvalidOperationException) + { + using XmlTextReader xmlTextReader = new XmlTextReader(new StringReader(streamContent)); + XmlSerializer serializer = _xmlSerializer.GetSerializerIgnoreNamespace(modelType); + + return serializer.Deserialize(xmlTextReader) + ?? throw new InvalidOperationException("Deserialization returned null"); + } + } + + private Type GetModelTypeForDataType(DataType dataType) + { + if (dataType.AppLogic?.ClassRef is not { } classRef) + { + throw new InvalidOperationException( + $"Data type {dataType.Id} does not have a appLogic.classRef in application metadata" + ); + } + + var type = _appModel.GetModelType(classRef); + return type; + } + + /// + /// XmlSerializer instances should be cached to avoid the overhead of creating them repeatedly. + /// + /// We cache two types of XmlSerializers: + /// 1. XmlSerializer: The default serializer for a given model type. + /// 2. XmlSerializer: A serializer that ignores the namespace of the XML, so we can deserialize XML without a namespace declaration. + /// + private class XmlSerializerCache + { + private readonly ConcurrentDictionary _xmlSerializers = new(); + private readonly ConcurrentDictionary _xmlSerializersWithOverride = new(); + + /// + /// Get a cached XmlSerializer for the given model type. + /// + public XmlSerializer GetSerializer(Type modelType) + { + return _xmlSerializers.GetOrAdd(modelType, t => new XmlSerializer(t)); + } + + /// + /// Get a cached XmlSerializer for the given model type, that ignores the namespace of the XML. + /// + public XmlSerializer GetSerializerIgnoreNamespace(Type modelType) + { + return _xmlSerializersWithOverride.GetOrAdd( + modelType, + t => + { + // In this backup try block we assume that the modelType has declared a namespace, + // but that the XML is without any namespace declaration. + string elementName = GetRootElementName(modelType); + + XmlAttributeOverrides attributeOverrides = new XmlAttributeOverrides(); + XmlAttributes attributes = new XmlAttributes(); + attributes.XmlRoot = new XmlRootAttribute(elementName); + attributeOverrides.Add(modelType, attributes); + return new XmlSerializer(t, attributeOverrides); + } + ); + } + + private static string GetRootElementName(Type modelType) + { + Attribute[] attributes = Attribute.GetCustomAttributes(modelType); + + foreach (var attribute in attributes) + { + if (attribute is XmlRootAttribute xmlRootAttribute) + { + return xmlRootAttribute.ElementName; + } + } + + return modelType.Name; + } + } +} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index d0e7e9119..00fd160c1 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -2,14 +2,13 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Text; -using System.Xml; -using System.Xml.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Constants; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.Serialization; +using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Models; @@ -29,6 +28,8 @@ public class DataClient : IDataClient private readonly PlatformSettings _platformSettings; private readonly ILogger _logger; private readonly IUserTokenProvider _userTokenProvider; + private readonly IAppMetadata _appMetadata; + private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly HttpClient _client; @@ -39,12 +40,16 @@ public class DataClient : IDataClient /// the logger /// A HttpClient from the built in HttpClient factory. /// Service to obtain json web token + /// + /// /// Telemetry for traces and metrics. public DataClient( IOptions platformSettings, ILogger logger, HttpClient httpClient, IUserTokenProvider userTokenProvider, + IAppMetadata appMetadata, + ModelSerializationService modelSerializationService, Telemetry? telemetry = null ) { @@ -57,6 +62,8 @@ public DataClient( httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); _client = httpClient; _userTokenProvider = userTokenProvider; + _appMetadata = appMetadata; + _modelSerializationService = modelSerializationService; _telemetry = telemetry; } @@ -70,6 +77,7 @@ public async Task InsertFormData( int instanceOwnerPartyId, string dataType ) + where T : notnull { using var activity = _telemetry?.StartInsertFormDataActivity(instanceGuid, instanceOwnerPartyId); Instance instance = new() { Id = $"{instanceOwnerPartyId}/{instanceGuid}", }; @@ -77,18 +85,26 @@ string dataType } /// - public async Task InsertFormData(Instance instance, string dataType, T dataToSerialize, Type type) + public async Task InsertFormData( + Instance instance, + string dataTypeString, + T dataToSerialize, + Type type + ) + where T : notnull { using var activity = _telemetry?.StartInsertFormDataActivity(instance); - string apiUrl = $"instances/{instance.Id}/data?dataType={dataType}"; + string apiUrl = $"instances/{instance.Id}/data?dataType={dataTypeString}"; string token = _userTokenProvider.GetUserToken(); - DataElement dataElement; - using MemoryStream stream = new MemoryStream(); - Serialize(dataToSerialize, type, stream); + var application = await _appMetadata.GetApplicationMetadata(); + var dataType = + application.DataTypes.Find(d => d.Id == dataTypeString) + ?? throw new InvalidOperationException($"Data type {dataTypeString} not found in applicationmetadata.json"); + + var (data, contentType) = _modelSerializationService.SerializeToStorage(dataToSerialize, dataType); - stream.Position = 0; - StreamContent streamContent = new StreamContent(stream); + StreamContent streamContent = new StreamContent(new MemoryAsStream(data)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/xml"); HttpResponseMessage response = await _client.PostAsync(token, apiUrl, streamContent); @@ -96,7 +112,7 @@ public async Task InsertFormData(Instance instance, string dataT { string instanceData = await response.Content.ReadAsStringAsync(); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release - dataElement = JsonConvert.DeserializeObject(instanceData)!; + var dataElement = JsonConvert.DeserializeObject(instanceData)!; return dataElement; } @@ -120,19 +136,20 @@ public async Task UpdateData( int instanceOwnerPartyId, Guid dataId ) + where T : notnull { using var activity = _telemetry?.StartUpdateDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; string token = _userTokenProvider.GetUserToken(); - using MemoryStream stream = new MemoryStream(); - - Serialize(dataToSerialize, type, stream); + //TODO: this method does not get enough information to know the content type from the DataType + // if we start to support more than XML + var serializedBytes = _modelSerializationService.SerializeToXml(dataToSerialize); + var contentType = "application/xml"; - stream.Position = 0; - StreamContent streamContent = new StreamContent(stream); - streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/xml"); + StreamContent streamContent = new StreamContent(new MemoryAsStream(serializedBytes)); + streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); HttpResponseMessage response = await _client.PutAsync(token, apiUrl, streamContent); @@ -147,24 +164,6 @@ Guid dataId throw await PlatformHttpException.CreateAsync(response); } - // Serializing using XmlWriter with UTF8 Encoding without generating BOM - // to avoid issue introduced with .Net when MS introduced BOM by default - // when serializing ref. https://github.com/dotnet/runtime/issues/63585 - // Will be fixed with https://github.com/dotnet/runtime/pull/75637 - internal static void Serialize(T dataToSerialize, Type type, Stream targetStream) - { - XmlWriterSettings xmlWriterSettings = new XmlWriterSettings() - { - Encoding = new UTF8Encoding(false), - NewLineHandling = NewLineHandling.None, - }; - XmlWriter xmlWriter = XmlWriter.Create(targetStream, xmlWriterSettings); - - XmlSerializer serializer = new(type); - - serializer.Serialize(xmlWriter, dataToSerialize); - } - /// public async Task GetBinaryData( string org, @@ -214,20 +213,44 @@ Guid dataId HttpResponseMessage response = await _client.GetAsync(token, apiUrl); if (response.IsSuccessStatusCode) { - using Stream stream = await response.Content.ReadAsStreamAsync(); - ModelDeserializer deserializer = new ModelDeserializer(_logger, type); - object? model = await deserializer.DeserializeAsync(stream, "application/xml"); + var bytes = await response.Content.ReadAsByteArrayAsync(); - if (deserializer.Error != null || model is null) + try + { + //TODO: this method does not get enough information to know the content type from the DataType + // if we start to support more than XML + return _modelSerializationService.DeserializeXml(bytes, type); + } + catch (Exception e) { - _logger.LogError($"Cannot deserialize XML form data read from storage: {deserializer.Error}"); - throw new ServiceException( - HttpStatusCode.Conflict, - $"Cannot deserialize XML form data from storage {deserializer.Error}" - ); + _logger.LogError(e, "Error deserializing form data"); + throw new ServiceException(HttpStatusCode.Conflict, "Error deserializing form data", e); } + } + + throw await PlatformHttpException.CreateAsync(response); + } + + /// + public async Task GetDataBytes( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId + ) + { + using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, instanceOwnerPartyId); + string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; + string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; + + string token = _userTokenProvider.GetUserToken(); + + HttpResponseMessage response = await _client.GetAsync(token, apiUrl); - return model; + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsByteArrayAsync(); } throw await PlatformHttpException.CreateAsync(response); @@ -464,7 +487,7 @@ HttpRequest request public async Task UpdateBinaryData( InstanceIdentifier instanceIdentifier, string? contentType, - string filename, + string? filename, Guid dataGuid, Stream stream ) @@ -475,11 +498,15 @@ Stream stream StreamContent content = new StreamContent(stream); ArgumentNullException.ThrowIfNull(contentType); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - content.Headers.ContentDisposition = new ContentDispositionHeaderValue(DispositionTypeNames.Attachment) + if (!string.IsNullOrEmpty(filename)) { - FileName = filename, - FileNameStar = filename - }; + content.Headers.ContentDisposition = new ContentDispositionHeaderValue(DispositionTypeNames.Attachment) + { + FileName = filename, + FileNameStar = filename + }; + } + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index 8d83bda8a..c9153a432 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; using System.Globalization; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; @@ -21,14 +21,15 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor private readonly int _instanceOwnerPartyId; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; - private readonly IAppModel _appModel; - private readonly LazyCache _cache = new(); + private readonly ModelSerializationService _modelSerializationService; + private readonly LazyCache _formDataCache = new(); + private readonly LazyCache> _binaryCache = new(); public CachedInstanceDataAccessor( Instance instance, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel + ModelSerializationService modelSerializationService ) { var splitApp = instance.AppId.Split("/"); @@ -40,120 +41,200 @@ IAppModel appModel Instance = instance; _dataClient = dataClient; _appMetadata = appMetadata; - _appModel = appModel; + _modelSerializationService = modelSerializationService; } public Instance Instance { get; } - public async Task GetSingleDataByType(string dataType) + /// + public async Task GetFormData(DataElementId dataElementId) { - var appMetadata = await _appMetadata.GetApplicationMetadata(); - var dataTypeObj = appMetadata.DataTypes.Find(d => d.Id == dataType); - if (dataTypeObj == null) - { - throw new InvalidOperationException($"Data type {dataType} not found in app metadata"); - } - if (dataTypeObj.MaxCount != 1) + return await _formDataCache.GetOrCreate( + dataElementId, + async () => + { + var binaryData = await GetBinaryData(dataElementId); + + return _modelSerializationService.DeserializeFromStorage(binaryData.Span, GetDataType(dataElementId)); + } + ); + } + + public async Task> GetBinaryData(DataElementId dataElementId) => + await _binaryCache.GetOrCreate( + dataElementId, + async () => + await _dataClient.GetDataBytes(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Guid) + ); + + /// + public DataElement GetDataElement(DataElementId dataElementId) + { + return Instance.Data.Find(d => d.Id == dataElementId.Id) + ?? throw new InvalidOperationException($"Data element with id {dataElementId.Id} not found in instance"); + } + + public DataType GetDataType(DataElementId dataElementId) + { + var dataElement = GetDataElement(dataElementId); + var appMetadata = _appMetadata.GetApplicationMetadata().Result; + var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType is null) { - throw new InvalidOperationException($"Data type {dataType} is not a single data type"); + throw new InvalidOperationException($"Data type {dataElement.DataType} not found in instance"); } - var dataElement = Instance.Data.Find(d => d.DataType == dataType); - if (dataElement == null) + + return dataType; + } + + public List GetDataElementChanges() + { + var changes = new List(); + foreach (var dataElement in Instance.Data) { - return null; + DataElementId dataElementId = dataElement; + object? data = _formDataCache.GetCachedValueOrDefault(dataElementId); + // Skip data elements that have not been fetched + if (data is null) + continue; + var dataType = GetDataType(dataElementId); + var previousBinary = _binaryCache.GetCachedValueOrDefault(dataElementId); + + ObjectUtils.InitializeAltinnRowId(data); + ObjectUtils.PrepareModelForXmlStorage(data); + + var (currentBinary, _) = _modelSerializationService.SerializeToStorage(data, dataType); + + if (!currentBinary.Span.SequenceEqual(previousBinary.Span)) + { + changes.Add( + new DataElementChange() + { + DataElement = dataElement, + CurrentFormData = data, + PreviousFormData = _modelSerializationService.DeserializeFromStorage( + previousBinary.Span, + dataType + ) + } + ); + } } - return await GetData(dataElement); + return changes; } - /// - public async Task GetData(DataElementId dataElementId) + internal Task UpdateInstanceData() { - return await _cache.GetOrCreate( - dataElementId, - async _ => - { - var appMetadata = await _appMetadata.GetApplicationMetadata(); - var dataElementIdString = dataElementId.Id.ToString(); - var dataElementType = Instance.Data.Find(d => d.Id == dataElementIdString)?.DataType; - var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElementType); - if (dataType == null) - { - throw new InvalidOperationException( - $"Data type {dataElementType ?? "unknown"} for data element id {dataElementId} not found in app metadata" - ); - } + return Task.CompletedTask; + } - if (dataType.AppLogic?.ClassRef != null) - { - return await GetFormData(dataElementId, dataType); - } + internal async Task SaveChanges(List changes, bool initializeRowId) + { + var tasks = new List(); - return await GetBinaryData(dataElementId); + foreach (var change in changes) + { + var dataType = GetDataType(change.DataElement); + if (initializeRowId) + { + ObjectUtils.InitializeAltinnRowId(change.CurrentFormData); } - ); + + var (binaryData, contentType) = _modelSerializationService.SerializeToStorage( + change.CurrentFormData, + dataType + ); + // Update cache so that we can compare with the saved data to ensure no changes after save + _binaryCache.Set(change.DataElement, binaryData); + tasks.Add( + _dataClient.UpdateBinaryData( + new InstanceIdentifier(Instance), + contentType, + null, + change.DataElement.Guid, + new MemoryAsStream(binaryData) + ) + ); + } + + await Task.WhenAll(tasks); } /// - /// Add data to the cache, so that it won't be fetched again + /// Add or replace existing data element data in the cache /// - public void Set(DataElementId dataElementId, object data) + internal void SetFormData(DataElementId dataElementId, object data) { - _cache.Set(dataElementId, data); + _formDataCache.Set(dataElementId, data); } /// - /// Simple wrapper around a ConcurrentDictionary using Lazy to ensure that the valueFactory is only called once + /// Simple wrapper around a Dictionary using Lazy to ensure that the valueFactory is only called once /// - /// The type of the key in the cache - /// The type of the object to cache - private sealed class LazyCache - where TKey : notnull - where TValue : notnull + private sealed class LazyCache { - private readonly ConcurrentDictionary>> _cache = new(); + private readonly Dictionary>> _cache = new(); - public async Task GetOrCreate(TKey key, Func> valueFactory) + public async Task GetOrCreate(DataElementId key, Func> valueFactory) { - Task task; + Lazy>? lazyTask; lock (_cache) { - task = _cache.GetOrAdd(key, innerKey => new Lazy>(() => valueFactory(innerKey))).Value; + if (!_cache.TryGetValue(key.Guid, out lazyTask)) + { + lazyTask = new Lazy>(valueFactory); + _cache.Add(key.Guid, lazyTask); + } } - return await task; + return await lazyTask.Value; } - public void Set(TKey key, TValue data) + public void Set(DataElementId key, T data) { lock (_cache) { - _cache.AddOrUpdate( - key, - _ => new Lazy>(Task.FromResult(data)), - (_, _) => new Lazy>(Task.FromResult(data)) - ); + _cache[key.Guid] = new Lazy>(Task.FromResult(data)); } } + + public T? GetCachedValueOrDefault(DataElementId id) + { + lock (_cache) + { + if ( + _cache.TryGetValue(id.Guid, out var lazyTask) + && lazyTask.IsValueCreated + && lazyTask.Value.IsCompletedSuccessfully + ) + { + return lazyTask.Value.Result; + } + } + return default; + } } - private async Task GetBinaryData(DataElementId dataElementId) + private async Task GetDataTypeByString(string dataTypeString) { - var data = await _dataClient.GetBinaryData(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Id); - return data; + var appMetadata = await _appMetadata.GetApplicationMetadata(); + var dataType = appMetadata.DataTypes.Find(d => d.Id == dataTypeString); + if (dataType is null) + { + throw new InvalidOperationException($"Data type {dataTypeString} not found in app metadata"); + } + + return dataType; } - private async Task GetFormData(DataElementId dataElementId, DataType dataType) + internal void VerifyDataElementsUnchanged() { - var modelType = _appModel.GetModelType(dataType.AppLogic.ClassRef); - - var data = await _dataClient.GetFormData( - _instanceGuid, - modelType, - _org, - _app, - _instanceOwnerPartyId, - dataElementId.Id - ); - return data; + var changes = GetDataElementChanges(); + if (changes.Count > 0) + { + throw new InvalidOperationException( + $"Data elements of type {string.Join(", ", changes.Select(c => c.DataElement.DataType).Distinct())} have been changed by validators" + ); + } } } diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index dc065fa31..b5ea85d98 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -28,30 +28,33 @@ Task InsertFormData( string app, int instanceOwnerPartyId, string dataType - ); + ) + where T : notnull; /// /// Stores the form /// /// The model type /// The instance that the data element belongs to - /// The data type with requirements + /// The data type with requirements /// The data element instance /// The class type describing the data /// The data element metadata - Task InsertFormData(Instance instance, string dataType, T dataToSerialize, Type type); + Task InsertFormData(Instance instance, string dataTypeString, T dataToSerialize, Type type) + where T : notnull; /// /// updates the form data /// /// The type /// The form data to serialize - /// The instanceid + /// The instance id /// The type for serialization /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. /// The instance owner id /// the data id + //TODO: [Obsolete in v9 in favour of a version that gets the dataType so we can support json and xml] Task UpdateData( T dataToSerialize, Guid instanceGuid, @@ -60,12 +63,13 @@ Task UpdateData( string app, int instanceOwnerPartyId, Guid dataId - ); + ) + where T : notnull; /// /// Gets the form data /// - /// The instanceid + /// The instance id /// The type for serialization /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. @@ -86,10 +90,21 @@ Guid dataId /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. /// The instance owner id - /// The instanceid + /// The instance id /// the data id Task GetBinaryData(string org, string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId); + /// + /// Similar to GetBinaryData, but returns a HttpResponseMessage instead of a cached stream + /// + /// Unique identifier of the organisation responsible for the app. + /// Application identifier which is unique within an organisation. + /// The instance owner id + /// The instance id + /// the data id + /// The raw HttpResponseMessage from the call to platform + Task GetDataBytes(string org, string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId); + /// /// Method that gets metadata on form attachments ordered by attachmentType /// @@ -180,7 +195,7 @@ HttpRequest request Task UpdateBinaryData( InstanceIdentifier instanceIdentifier, string? contentType, - string filename, + string? filename, Guid dataGuid, Stream stream ); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index ab8080388..cf8b86d12 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -52,7 +52,7 @@ public SingleDataElementAccessor(Instance instance, DataElement dataElement, obj public Instance Instance { get; } - public Task GetData(DataElementId dataElementId) + public Task GetFormData(DataElementId dataElementId) { if (dataElementId != _dataElement) { @@ -65,15 +65,20 @@ public Task GetData(DataElementId dataElementId) return Task.FromResult(_data); } - public Task GetSingleDataByType(string dataType) + public Task> GetBinaryData(DataElementId dataElementId) { - if (_dataElement.DataType != dataType) + return Task.FromException>(new NotImplementedException()); + } + + public DataElement GetDataElement(DataElementId dataElementId) + { + if (dataElementId != _dataElement) { - return Task.FromException( - new InvalidOperationException("Data type does not match the data element") + throw new InvalidOperationException( + "Use the new ILayoutEvaluatorStateInitializer interface to support multiple data models and subforms" ); } - return Task.FromResult(_data); + return _dataElement; } } diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index fda01b991..0553ef2c9 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -21,15 +21,5 @@ public class DataPatchResult /// /// Get updated data elements that have app logic in a dictionary with the data element id as key. /// - public Dictionary GetUpdatedData() - { - return ChangedDataElements - .Where(d => d.HasAppLogic) - .ToDictionary( - d => Guid.Parse(d.DataElement.Id), - d => - d.CurrentValue - ?? throw new InvalidOperationException("Data element has app logic but no current value") - ); - } + public required Dictionary UpdatedData { get; init; } } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index 33cca223b..fa9a8a050 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -2,9 +2,8 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Altinn.App.Core.Features; -using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -21,7 +20,7 @@ internal class PatchService : IPatchService { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; - private readonly IAppModel _appModel; + private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly IValidationService _validationService; private readonly IEnumerable _dataProcessors; @@ -37,7 +36,7 @@ public PatchService( IDataClient dataClient, IValidationService validationService, IEnumerable dataProcessors, - IAppModel appModel, + ModelSerializationService modelSerializationService, Telemetry? telemetry = null ) { @@ -45,7 +44,7 @@ public PatchService( _dataClient = dataClient; _validationService = validationService; _dataProcessors = dataProcessors; - _appModel = appModel; + _modelSerializationService = modelSerializationService; _telemetry = telemetry; } @@ -62,22 +61,30 @@ public async Task> ApplyPatches( InstanceIdentifier instanceIdentifier = new(instance); AppIdentifier appIdentifier = (await _appMetadata.GetApplicationMetadata()).AppIdentifier; - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); - var changes = new List(); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _appMetadata, + _modelSerializationService + ); - foreach (var (dataElementId, jsonPatch) in patches) + List changesAfterPatch = new(); + + foreach (var (dataElementGuid, jsonPatch) in patches) { - var dataElement = instance.Data.Find(d => d.Id == dataElementId.ToString()); + var dataElement = instance.Data.Find(d => d.Id == dataElementGuid.ToString()); + if (dataElement is null) { return new DataPatchError() { Title = "Unknown data element to patch", - Detail = $"Data element with id {dataElementId} not found in instance", + Detail = $"Data element with id {dataElementGuid} not found in instance", }; } + DataElementId dataElementId = dataElement; - var oldModel = await dataAccessor.GetData(dataElement); + var oldModel = await dataAccessor.GetFormData(dataElementId); // TODO: Fetch data in parallel var oldModelNode = JsonSerializer.SerializeToNode(oldModel); var patchResult = jsonPatch.Apply(oldModelNode); @@ -110,14 +117,34 @@ public async Task> ApplyPatches( }; } var newModel = newModelResult.Ok; + // Reset dataAccessor to provide the patched model. + dataAccessor.SetFormData(dataElement, newModel); + + changesAfterPatch.Add( + new DataElementChange + { + DataElement = dataElementId, + PreviousFormData = oldModel, + CurrentFormData = newModel + } + ); + } - foreach (var dataProcessor in _dataProcessors) + foreach (var dataProcessor in _dataProcessors) + { + foreach (var change in changesAfterPatch) { using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataProcessor); try { // TODO: Create new dataProcessor interface that takes multiple models at the same time. - await dataProcessor.ProcessDataWrite(instance, dataElementId, newModel, oldModel, language); + await dataProcessor.ProcessDataWrite( + instance, + change.DataElement.Guid, + change.CurrentFormData, + change.PreviousFormData, + language + ); } catch (Exception e) { @@ -125,34 +152,15 @@ public async Task> ApplyPatches( throw; } } - ObjectUtils.InitializeAltinnRowId(newModel); - ObjectUtils.PrepareModelForXmlStorage(newModel); - changes.Add( - new DataElementChange - { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, - DataElement = dataElement, - PreviousValue = oldModel, - CurrentValue = newModel, - } - ); - - // save form data to storage - await _dataClient.UpdateData( - newModel, - instanceIdentifier.InstanceGuid, - newModel.GetType(), - appIdentifier.Org, - appIdentifier.App, - instanceIdentifier.InstanceOwnerPartyId, - dataElementId - ); - - // Ensure that validation runs on the modified model. - dataAccessor.Set(dataElement, newModel); } + // Get all changes to data elements by comparing the serialized values + var changes = dataAccessor.GetDataElementChanges(); + // Start saving changes in parallel with validation + Task saveChanges = dataAccessor.SaveChanges(changes, initializeRowId: true); + // Update instance data to reflect the changes and save created data elements + await dataAccessor.UpdateInstanceData(); + var validationIssues = await _validationService.ValidateIncrementalFormData( instance, dataAccessor, @@ -162,7 +170,37 @@ await _dataClient.UpdateData( language ); - return new DataPatchResult { ChangedDataElements = changes, ValidationIssues = validationIssues }; + // don't await saving until validation is done, so that they run in parallel + await saveChanges; + + if (true) // TODO: only run in development mode + { + // Ensure that validation did not change the data elements + dataAccessor.VerifyDataElementsUnchanged(); + } + + var updatedData = changes.ToDictionary( + d => d.DataElement.Guid, + d => + d.CurrentFormData + ?? throw new InvalidOperationException("Data element has app logic but no current value") + ); + // Ensure that all data elements that were patched are included in the updated data + // (even if they were not changed or the change was reverted by dataProcessor) + foreach (var patchedElementGuid in patches.Keys.Where(g => !updatedData.ContainsKey(g))) + { + var dataElement = + instance.Data.Find(d => d.Id == patchedElementGuid.ToString()) + ?? throw new InvalidOperationException("Data element not found in instance"); + updatedData.Add(patchedElementGuid, dataAccessor.GetFormData(dataElement)); + } + + return new DataPatchResult + { + ChangedDataElements = changes, + UpdatedData = updatedData, + ValidationIssues = validationIssues, + }; } private static ServiceResult DeserializeModel(Type type, JsonNode? patchResult) diff --git a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs index 4bcdd9498..a0eeabef1 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs @@ -1,6 +1,6 @@ using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; @@ -20,7 +20,7 @@ public class ProcessNavigator : IProcessNavigator private readonly ILogger _logger; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; - private readonly IAppModel _appModel; + private readonly ModelSerializationService _modelSerialization; /// /// Initialize a new instance of @@ -31,7 +31,7 @@ public ProcessNavigator( ILogger logger, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel + ModelSerializationService modelSerialization ) { _processReader = processReader; @@ -39,7 +39,7 @@ IAppModel appModel _logger = logger; _dataClient = dataClient; _appMetadata = appMetadata; - _appModel = appModel; + _modelSerialization = modelSerialization; } /// @@ -114,7 +114,7 @@ private async Task> NextFollowAndFilterGateways( instance, _dataClient, _appMetadata, - _appModel + _modelSerialization ); filteredList = await gatewayFilter.FilterAsync( outgoingFlows, diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index 2617e3535..e2c0f6db4 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -1,11 +1,12 @@ using System.Globalization; +using System.Reflection; using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; @@ -19,9 +20,9 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; - private readonly IAppModel _appModel; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IOptions _appSettings; + private readonly ModelSerializationService _modelSerializer; /// /// Initializes a new instance of the class. @@ -29,16 +30,16 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer public ProcessTaskFinalizer( IAppMetadata appMetadata, IDataClient dataClient, - IAppModel appModel, + ModelSerializationService modelSerializer, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IOptions appSettings ) { _appMetadata = appMetadata; _dataClient = dataClient; - _appModel = appModel; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _appSettings = appSettings; + _modelSerializer = modelSerializer; } /// @@ -47,7 +48,7 @@ public async Task Finalize(string taskId, Instance instance) ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); List connectedDataTypes = applicationMetadata.DataTypes.FindAll(dt => dt.TaskId == taskId); - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _appModel); + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerializer); var changedDataElements = await RunRemoveFieldsInModelOnTaskComplete( instance, dataAccessor, @@ -60,7 +61,7 @@ public async Task Finalize(string taskId, Instance instance) await Task.WhenAll( changedDataElements.Select(async dataElement => { - var data = await dataAccessor.GetData(dataElement); + var data = await dataAccessor.GetFormData(dataElement); return _dataClient.UpdateData( data, Guid.Parse(instance.Id.Split('/')[1]), @@ -128,7 +129,7 @@ private async Task RemoveFieldsOnTaskComplete( ) { bool isModified = false; - var data = await dataAccessor.GetData(dataElement); + var data = await dataAccessor.GetFormData(dataElement); // remove AltinnRowIds isModified |= ObjectUtils.RemoveAltinnRowId(data); @@ -151,8 +152,10 @@ private async Task RemoveFieldsOnTaskComplete( } // Remove shadow fields + // TODO: Use reflection or code generation instead of JsonSerializer if (dataType.AppLogic?.ShadowFields?.Prefix != null) { + Type saveToModelType = data.GetType(); string serializedData = JsonSerializerIgnorePrefix.Serialize(data, dataType.AppLogic.ShadowFields.Prefix); if (dataType.AppLogic.ShadowFields.SaveToDataType != null) { @@ -167,8 +170,11 @@ private async Task RemoveFieldsOnTaskComplete( ); } - Type saveToModelType = _appModel.GetModelType(saveToDataType.AppLogic.ClassRef); - object? updatedData = JsonSerializer.Deserialize(serializedData, saveToModelType); + object updatedData = + JsonSerializer.Deserialize(serializedData, saveToModelType) + ?? throw new JsonException( + "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" + ); // Save a new data element with the cleaned data without shadow fields. Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); string app = instance.AppId.Split("/")[1]; @@ -186,14 +192,23 @@ private async Task RemoveFieldsOnTaskComplete( } else { - // Remove the shadow fields from the data - - data = - JsonSerializer.Deserialize(serializedData, data.GetType()) + // Remove the shadow fields from the data using JsonSerializer + var newData = + JsonSerializer.Deserialize(serializedData, saveToModelType) ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); - (dataAccessor as CachedInstanceDataAccessor)?.Set(dataElement, data); + // Copy all properties with a public setter from newData to data + foreach ( + var propertyInfo in saveToModelType + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.CanWrite) + ) + { + object? value = propertyInfo.GetValue(newData); + propertyInfo.SetValue(data, value); + } + isModified = true; // TODO: Detect if modifications were made } } diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index 0531d44e8..1f67449a8 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -16,7 +16,7 @@ public interface IValidationService /// Accessor for instance data to be validated /// The task to run validations for (overriding instance.Process?.CurrentTask?.ElementId) /// List of to ignore - /// + /// only run validators that implements incremental validation /// The language to run validations in /// List of validation issues for this data element Task> ValidateInstanceAtTask( diff --git a/src/Altinn.App.Core/Models/DataElementId.cs b/src/Altinn.App.Core/Models/DataElementId.cs index 88eece842..9b562a4c2 100644 --- a/src/Altinn.App.Core/Models/DataElementId.cs +++ b/src/Altinn.App.Core/Models/DataElementId.cs @@ -5,19 +5,38 @@ namespace Altinn.App.Core.Models; /// /// Wrapper type for a /// -/// The guid ID -public readonly record struct DataElementId(Guid Id) +/// The guid as a Guid +/// The guid ID as string +/// The data type id from app metadata +public readonly record struct DataElementId(Guid Guid, string Id, string DataType) { + /// + /// Override equality to only compare the guid + /// + public bool Equals(DataElementId other) + { + return Guid.Equals(other.Guid); + } + + /// + /// Override equality to only compare the guid + /// + public override int GetHashCode() + { + return Guid.GetHashCode(); + } + /// /// Implicit conversion to allow DataElements to be used as DataElementIds /// - public static implicit operator DataElementId(DataElement dataElement) => new(Guid.Parse(dataElement.Id)); + public static implicit operator DataElementId(DataElement dataElement) => + new(Guid.Parse(dataElement.Id), dataElement.Id, dataElement.DataType); /// /// Make the ToString method return the ID /// public override string ToString() { - return Id.ToString(); + return Id; } } diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs index bb8645269..466a7d8ac 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs @@ -77,7 +77,7 @@ public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string /// Weather the issue is from a validator that correctly implements . /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] - [JsonPropertyName("NoIncrementalUpdates")] + [JsonPropertyName("noIncrementalUpdates")] public bool NoIncrementalUpdates { get; set; } /// diff --git a/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt b/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt index 00200e139..1a532a970 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/DataControllerPatchTests.InvalidTestValue_ReturnsConflict.verified.txt @@ -63,6 +63,15 @@ ], IdFormat: W3C, Kind: Server + }, + { + ActivityName: SerializationService.DeserializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C } ], Metrics: [] diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 6952418de..54cac5281 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -979,16 +979,20 @@ public async Task IgnoredValidators_NotExecuted() .Setup(fdv => fdv.HasRelevantChanges(It.IsAny(), It.IsAny())) .Returns(true); - var patch = new JsonPatch( - PatchOperation.Replace(JsonPointer.Create("melding", "name"), JsonNode.Parse("\"Ola Olsen\"")) - ); + var path = JsonPointer.Create("melding", "name"); + var patch = new JsonPatch(PatchOperation.Replace(path, JsonNode.Parse("\"Ola Olsen\""))); var (_, _, parsedResponse1) = await CallPatchApi(patch, ["ignored"], HttpStatusCode.OK); // Verify that no issues from the ignored validator are present parsedResponse1.ValidationIssues.Should().NotContainKey("ignored"); - var (_, _, parsedResponse2) = await CallPatchApi(patch, null, HttpStatusCode.OK); + var patch2 = new JsonPatch( + PatchOperation.Test(path, JsonNode.Parse("\"Ola Olsen\"")), + PatchOperation.Replace(path, JsonNode.Parse("\"Ola Nielsen\"")) + ); + + var (_, _, parsedResponse2) = await CallPatchApi(patch2, null, HttpStatusCode.OK); // Verify that issues from the ignored validator are present parsedResponse2 diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index 2c7c44dee..f07c48f80 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -109,6 +109,15 @@ ActivityName: ProcessClient.GetProcessDefinition, IdFormat: W3C }, + { + ActivityName: SerializationService.DeserializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C + }, { ActivityName: Validation.RunValidator, Tags: [ diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index 704bd1768..12b98a8fd 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -204,6 +204,24 @@ ActivityName: ProcessReader.IsProcessTask, IdFormat: W3C }, + { + ActivityName: SerializationService.DeserializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C + }, + { + ActivityName: SerializationService.DeserializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C + }, { ActivityName: Validation.RunValidator, Tags: [ diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs index 9275ee183..6cd9992cf 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerTests.cs @@ -2,6 +2,7 @@ using Altinn.App.Api.Controllers; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -45,13 +46,7 @@ public async Task ValidateInstance_returns_NotFound_when_GetInstance_returns_nul .Returns(Task.FromResult(null!)); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert @@ -71,13 +66,7 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc .Returns(Task.FromResult(instance)); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); // Assert var exception = await Assert.ThrowsAsync( @@ -101,13 +90,7 @@ public async Task ValidateInstance_throws_ValidationException_when_Instance_Proc .Returns(Task.FromResult(instance)); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); // Assert var exception = await Assert.ThrowsAsync( @@ -155,13 +138,7 @@ public async Task ValidateInstance_returns_OK_with_messages() .ReturnsAsync(validationResult); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert @@ -195,13 +172,7 @@ public async Task ValidateInstance_returns_403_when_not_authorized() .Throws(exception); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); var result = await validateController.ValidateInstance(Org, App, InstanceOwnerPartyId, _instanceId); // Assert @@ -235,13 +206,7 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() .Throws(exception); // Act - var validateController = new ValidateController( - _instanceMock.Object, - _validationMock.Object, - _appMetadataMock.Object, - _dataClientMock.Object, - _appModelMock.Object - ); + var validateController = GetValidateController(); // Assert var thrownException = await Assert.ThrowsAsync( @@ -249,4 +214,15 @@ public async Task ValidateInstance_throws_PlatformHttpException_when_not_403() ); Assert.Equal(exception, thrownException); } + + private ValidateController GetValidateController() + { + return new ValidateController( + _instanceMock.Object, + _validationMock.Object, + _appMetadataMock.Object, + _dataClientMock.Object, + new ModelSerializationService(_appModelMock.Object) + ); + } } diff --git a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs index 44aecce33..826d15557 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ValidateControllerValidateDataTests.cs @@ -2,6 +2,7 @@ using Altinn.App.Api.Controllers; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -203,7 +204,7 @@ public async Task TestValidateData(ValidateDataTestScenario testScenario) _validationMock.Object, _appMetadataMock.Object, _dataClientMock.Object, - _appModelMock.Object + new ModelSerializationService(_appModelMock.Object) ); // Act and Assert diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index 2320af831..4621dbccb 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -2,7 +2,6 @@ using Altinn.App.Api.Tests.Data; using Altinn.App.Core.Extensions; using Altinn.App.Core.Helpers.Serialization; -using Altinn.App.Core.Infrastructure.Clients.Storage; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Models; @@ -17,6 +16,7 @@ public class DataClientMock : IDataClient { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IAppMetadata _appMetadata; + private readonly ModelSerializationService _modelSerialization; private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -25,6 +25,7 @@ public class DataClientMock : IDataClient public DataClientMock( IAppMetadata appMetadata, + ModelSerializationService modelSerialization, IHttpContextAccessor httpContextAccessor, ILogger logger ) @@ -32,6 +33,7 @@ ILogger logger _httpContextAccessor = httpContextAccessor; _logger = logger; _appMetadata = appMetadata; + _modelSerialization = modelSerialization; } public async Task DeleteBinaryData( @@ -78,7 +80,7 @@ is not DataElement dataElement dataElement.DeleteStatus = new() { IsHardDeleted = true, HardDeleted = DateTime.UtcNow }; - WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); + await WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); return true; } @@ -100,17 +102,28 @@ is not DataElement dataElement } } - public Task GetBinaryData(string org, string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId) + public async Task GetBinaryData( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId + ) { - string dataPath = TestData.GetDataBlobPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); + return new MemoryStream(await GetDataBytes(org, app, instanceOwnerPartyId, instanceGuid, dataId)); + } - Stream ms = new MemoryStream(); - using (FileStream file = new(dataPath, FileMode.Open, FileAccess.Read)) - { - file.CopyTo(ms); - } + public async Task GetDataBytes( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId + ) + { + string dataPath = TestData.GetDataBlobPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); - return Task.FromResult(ms); + return await File.ReadAllBytesAsync(dataPath); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously @@ -155,68 +168,80 @@ public async Task GetFormData( Guid dataId ) { + var dataElementPath = TestData.GetDataElementPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); + var dataElement = JsonSerializer.Deserialize( + await File.ReadAllBytesAsync(dataElementPath), + _jsonSerializerOptions + ); + var application = await _appMetadata.GetApplicationMetadata(); + var dataType = + application.DataTypes.Find(d => d.Id == dataElement?.DataType) + ?? throw new InvalidOperationException( + $"Data type {dataElement?.DataType} not found in applicationmetadata.json" + ); + string dataPath = TestData.GetDataBlobPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); - using var sourceStream = File.Open(dataPath, FileMode.OpenOrCreate); + var dataBytes = await File.ReadAllBytesAsync(dataPath); - ModelDeserializer deserializer = new(_logger, type); - var formData = await deserializer.DeserializeAsync(sourceStream, "application/xml"); + var formData = _modelSerialization.DeserializeFromStorage(dataBytes, dataType); - // var formData = serializer.Deserialize(sourceStream); return formData ?? throw new Exception("Unable to deserialize form data"); } - public async Task InsertFormData(Instance instance, string dataType, T dataToSerialize, Type type) + public async Task InsertFormData( + Instance instance, + string dataTypeString, + T dataToSerialize, + Type type + ) + where T : notnull { Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); string app = instance.AppId.Split("/")[1]; string org = instance.Org; int instanceOwnerId = int.Parse(instance.InstanceOwner.PartyId); - return await InsertFormData(dataToSerialize, instanceGuid, type, org, app, instanceOwnerId, dataType); + return await InsertFormData(dataToSerialize, instanceGuid, type, org, app, instanceOwnerId, dataTypeString); } - public Task InsertFormData( + public async Task InsertFormData( T dataToSerialize, Guid instanceGuid, Type type, string org, string app, int instanceOwnerPartyId, - string dataType + string dataTypeString ) + where T : notnull { + var application = await _appMetadata.GetApplicationMetadata(); + var dataType = + application.DataTypes.Find(d => d.Id == dataTypeString) + ?? throw new InvalidOperationException($"Data type {dataTypeString} not found in applicationmetadata.json"); Guid dataGuid = Guid.NewGuid(); string dataPath = TestData.GetDataDirectory(org, app, instanceOwnerPartyId, instanceGuid); + var (serializedBytes, contentType) = _modelSerialization.SerializeToStorage(dataToSerialize, dataType); DataElement dataElement = new() { Id = dataGuid.ToString(), InstanceGuid = instanceGuid.ToString(), - DataType = dataType, + DataType = dataTypeString, ContentType = "application/xml", }; - try - { - Directory.CreateDirectory(dataPath + @"blob"); + Directory.CreateDirectory(dataPath + @"blob"); - using (Stream stream = File.Open(dataPath + @"blob/" + dataGuid, FileMode.Create, FileAccess.ReadWrite)) - { - DataClient.Serialize(dataToSerialize, type, stream); - } + await File.WriteAllBytesAsync(dataPath + @"blob/" + dataGuid, serializedBytes.ToArray()); - WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); - } - catch -#pragma warning disable S108 // Nested blocks of code should not be left empty - { } -#pragma warning restore S108 // Nested blocks of code should not be left empty + await WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); - return Task.FromResult(dataElement); + return dataElement; } - public Task UpdateData( + public async Task UpdateData( T dataToSerialize, Guid instanceGuid, Type type, @@ -225,12 +250,19 @@ public Task UpdateData( int instanceOwnerPartyId, Guid dataGuid ) + where T : notnull { ArgumentNullException.ThrowIfNull(dataToSerialize); string dataPath = TestData.GetDataDirectory(org, app, instanceOwnerPartyId, instanceGuid); DataElement? dataElement = GetDataElements(org, app, instanceOwnerPartyId, instanceGuid) .FirstOrDefault(de => de.Id == dataGuid.ToString()); + var application = await _appMetadata.GetApplicationMetadata(); + var dataType = + application.DataTypes.Find(d => d.Id == dataElement?.DataType) + ?? throw new InvalidOperationException( + $"Data type {dataElement?.DataType} not found in applicationmetadata.json" + ); if (dataElement == null) { @@ -241,21 +273,14 @@ Guid dataGuid Directory.CreateDirectory(dataPath + @"blob"); - using ( - Stream stream = File.Open( - dataPath + $@"blob{Path.DirectorySeparatorChar}" + dataGuid, - FileMode.Create, - FileAccess.ReadWrite - ) - ) - { - DataClient.Serialize(dataToSerialize, type, stream); - } + var (serializedBytes, contentType) = _modelSerialization.SerializeToStorage(dataToSerialize, dataType); + await File.WriteAllBytesAsync(Path.Join(dataPath, "blob", dataGuid.ToString()), serializedBytes.ToArray()); dataElement.LastChanged = DateTime.UtcNow; - WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); + dataElement.Size = serializedBytes.Length; + await WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); - return Task.FromResult(dataElement); + return dataElement; } public async Task InsertBinaryData( @@ -308,39 +333,30 @@ HttpRequest request dataElement.Size = filesize; - WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); + await WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); return dataElement; } - public async Task InsertBinaryData( - string instanceId, - string dataType, - string contentType, - string filename, + public async Task UpdateBinaryData( + InstanceIdentifier instanceIdentifier, + string? contentType, + string? filename, + Guid dataGuid, Stream stream ) { Application application = await _appMetadata.GetApplicationMetadata(); - var instanceIdParts = instanceId.Split("/"); - - Guid dataGuid = Guid.NewGuid(); string org = application.Org; string app = application.Id.Split("/")[1]; - int instanceOwnerId = int.Parse(instanceIdParts[0]); - Guid instanceGuid = Guid.Parse(instanceIdParts[1]); - string dataPath = TestData.GetDataDirectory(org, app, instanceOwnerId, instanceGuid); - - DataElement dataElement = - new() - { - Id = dataGuid.ToString(), - InstanceGuid = instanceGuid.ToString(), - DataType = dataType, - ContentType = contentType, - }; + string dataPath = TestData.GetDataDirectory( + org, + app, + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid + ); if (!Directory.Exists(Path.GetDirectoryName(dataPath))) { @@ -356,7 +372,7 @@ Stream stream using ( Stream streamToWriteTo = File.Open( dataPath + @"blob/" + dataGuid, - FileMode.OpenOrCreate, + FileMode.Truncate, FileAccess.ReadWrite, FileShare.ReadWrite ) @@ -367,24 +383,17 @@ Stream stream streamToWriteTo.Flush(); filesize = streamToWriteTo.Length; } + var dataElement = + GetDataElements(org, app, instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid) + .FirstOrDefault(de => de.Id == dataGuid.ToString()) + ?? throw new Exception($"Data element with id {dataGuid} not found in instance"); dataElement.Size = filesize; - WriteDataElementToFile(dataElement, org, app, instanceOwnerId); + await WriteDataElementToFile(dataElement, org, app, instanceIdentifier.InstanceOwnerPartyId); return dataElement; } - public Task UpdateBinaryData( - InstanceIdentifier instanceIdentifier, - string? contentType, - string filename, - Guid dataGuid, - Stream stream - ) - { - throw new NotImplementedException(); - } - public async Task InsertBinaryData( string instanceId, string dataType, @@ -442,7 +451,7 @@ public async Task InsertBinaryData( } dataElement.Size = filesize; - WriteDataElementToFile(dataElement, org, app, instanceOwnerId); + await WriteDataElementToFile(dataElement, org, app, instanceOwnerId); return dataElement; } @@ -459,18 +468,18 @@ HttpRequest request throw new NotImplementedException(); } - public Task Update(Instance instance, DataElement dataElement) + public async Task Update(Instance instance, DataElement dataElement) { string org = instance.Org; string app = instance.AppId.Split("/")[1]; int instanceOwnerId = int.Parse(instance.InstanceOwner.PartyId); - WriteDataElementToFile(dataElement, org, app, instanceOwnerId); + await WriteDataElementToFile(dataElement, org, app, instanceOwnerId); - return Task.FromResult(dataElement); + return dataElement; } - public Task LockDataElement(InstanceIdentifier instanceIdentifier, Guid dataGuid) + public async Task LockDataElement(InstanceIdentifier instanceIdentifier, Guid dataGuid) { // 🤬The signature does not take org/app, // but our test data is organized by org/app. @@ -487,11 +496,11 @@ public Task LockDataElement(InstanceIdentifier instanceIdentifier, throw new Exception("Data element not found."); } element.Locked = true; - WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); - return Task.FromResult(element); + await WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); + return element; } - public Task UnlockDataElement(InstanceIdentifier instanceIdentifier, Guid dataGuid) + public async Task UnlockDataElement(InstanceIdentifier instanceIdentifier, Guid dataGuid) { // 🤬The signature does not take org/app, // but our test data is organized by org/app. @@ -508,11 +517,11 @@ public Task UnlockDataElement(InstanceIdentifier instanceIdentifier throw new Exception("Data element not found."); } element.Locked = false; - WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); - return Task.FromResult(element); + await WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); + return element; } - private static void WriteDataElementToFile( + private static async Task WriteDataElementToFile( DataElement dataElement, string org, string app, @@ -527,12 +536,8 @@ int instanceOwnerPartyId Guid.Parse(dataElement.Id) ); - string jsonData = JsonSerializer.Serialize(dataElement, _jsonSerializerOptions); - - using StreamWriter sw = new(dataElementPath); - - sw.Write(jsonData.ToString()); - sw.Close(); + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(dataElement, _jsonSerializerOptions); + await File.WriteAllBytesAsync(dataElementPath, jsonBytes); } private List GetDataElements(string org, string app, int instanceOwnerId, Guid instanceId) diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 517748e96..e1f7569bb 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -7042,7 +7042,7 @@ "type": "string", "nullable": true }, - "NoIncrementalUpdates": { + "noIncrementalUpdates": { "type": "boolean" }, "customTextKey": { diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 49b0c3ed5..c1db9ad5f 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -4533,7 +4533,7 @@ components: source: type: string nullable: true - NoIncrementalUpdates: + noIncrementalUpdates: type: boolean customTextKey: type: string diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs index a0b954875..70eab4a60 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/LegacyIValidationFormDataTests.cs @@ -26,7 +26,12 @@ public class LegacyIValidationFormDataTests Title = new LanguageString() { { "nb", "test" } }, DataTypes = new List() { - new DataType() { Id = "test", TaskId = "Task_1" }, + new DataType() + { + Id = "test", + TaskId = "Task_1", + AppLogic = new() { ClassRef = typeof(TestModel).FullName } + }, }, }; @@ -58,12 +63,12 @@ public LegacyIValidationFormDataTests() public async Task ValidateFormData_WithErrors() { // Arrange - var data = new object(); + var data = new TestModel(); _instanceValidator - .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) + .Setup(iv => iv.ValidateData(It.IsAny(), It.IsAny())) .Returns( - (object _, ModelStateDictionary modelState) => + (TestModel _, ModelStateDictionary modelState) => { modelState.AddModelError("test", "test"); modelState.AddModelError("ddd", "*FIXED*test"); @@ -72,7 +77,7 @@ public async Task ValidateFormData_WithErrors() ) .Verifiable(Times.Once); - _instanceDataAccessor.Setup(ida => ida.GetData(_dataElement)).ReturnsAsync(data); + _instanceDataAccessor.Setup(ida => ida.GetFormData(_dataElement)).ReturnsAsync(data); // Act var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); @@ -151,7 +156,7 @@ public async Task ValidateErrorAndMappingWithCustomModel(string errorKey, string } ) .Verifiable(Times.Once); - _instanceDataAccessor.Setup(ida => ida.GetData(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); + _instanceDataAccessor.Setup(ida => ida.GetFormData(_dataElement)).ReturnsAsync(data).Verifiable(Times.Once); // Act var result = await _validator.Validate(_instance, _instanceDataAccessor.Object, "Task_1", null); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index ae2f66b99..21e1f236c 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -18,7 +19,7 @@ namespace Altinn.App.Core.Tests.Features.Validators.LegacyValidationServiceTests public sealed class ValidationServiceTests : IDisposable { - private class MyModel + public class MyModel { [JsonPropertyName("name")] public string? Name { get; set; } @@ -103,16 +104,18 @@ private class MyModel new(MockBehavior.Strict) { Name = "alwaysDataElementValidator" }; private readonly Mock _formDataValidatorAlwaysMock = new(MockBehavior.Strict) { Name = "alwaysFormDataValidator" }; + private readonly ModelSerializationService _modelSerialization; private readonly ServiceCollection _serviceCollection = new(); public ValidationServiceTests() { + _modelSerialization = new ModelSerializationService(_appModelMock.Object); _dataAccessor = new CachedInstanceDataAccessor( _defaultInstance, _dataClientMock.Object, _appMetadataMock.Object, - _appModelMock.Object + _modelSerialization ); _serviceCollection.AddSingleton(_loggerMock.Object); _serviceCollection.AddSingleton(_dataClientMock.Object); @@ -244,16 +247,9 @@ private void SetupDataClient(MyModel data) { _dataClientMock .Setup(d => - d.GetFormData( - _defaultInstanceId, - data.GetType(), - DefaultOrg, - DefaultApp, - DefaultPartyId, - _defaultDataElementId - ) + d.GetDataBytes(DefaultOrg, DefaultApp, DefaultPartyId, _defaultInstanceId, _defaultDataElementId) ) - .ReturnsAsync(data) + .ReturnsAsync(_modelSerialization.SerializeToXml(data).ToArray()) .Verifiable(Times.AtLeastOnce); } @@ -321,11 +317,9 @@ public async Task ValidateFormData_WithSpecificValidator() { new() { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, DataElement = _defaultDataElement, - PreviousValue = previousData, - CurrentValue = data + PreviousFormData = previousData, + CurrentFormData = data } }, null, @@ -361,28 +355,28 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa await using var serviceProvider = _serviceCollection.BuildServiceProvider(); var validatorService = serviceProvider.GetRequiredService(); + var data = new MyModel { Name = "Kari" }; + List dataElementChanges = + [ + new DataElementChange() + { + DataElement = _defaultDataElement, + CurrentFormData = data, + PreviousFormData = data, + } + ]; + SetupDataClient(data); var dataAccessor = new CachedInstanceDataAccessor( _defaultInstance, _dataClientMock.Object, _appMetadataMock.Object, - _appModelMock.Object + _modelSerialization ); - var data = new MyModel { Name = "Kari" }; - dataAccessor.Set(_defaultDataElement, data); var resultData = await validatorService.ValidateIncrementalFormData( _defaultInstance, dataAccessor, "Task_1", - [ - new DataElementChange() - { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, - DataElement = _defaultDataElement, - CurrentValue = data, - PreviousValue = data, - } - ], + dataElementChanges, null, DefaultLanguage ); @@ -454,7 +448,7 @@ List CreateIssues(string code) _defaultInstance, _dataClientMock.Object, _appMetadataMock.Object, - _appModelMock.Object + _modelSerialization ); var taskResult = await validationService.ValidateInstanceAtTask( diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 11df633eb..770128e28 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -359,19 +359,15 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu { new DataElementChange() { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, DataElement = dataElement, - CurrentValue = "currentValue", - PreviousValue = "previousValue" + CurrentFormData = "currentValue", + PreviousFormData = "previousValue" }, new DataElementChange() { - HasAppLogic = true, - ChangeType = DataElementChangeType.Update, DataElement = dataElementNoValidation, - CurrentValue = "currentValue", - PreviousValue = "previousValue" + CurrentFormData = "currentValue", + PreviousFormData = "previousValue" } }; diff --git a/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs b/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs new file mode 100644 index 000000000..ef72ae3d2 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs @@ -0,0 +1,114 @@ +using Altinn.App.Core.Helpers; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Helpers; + +public class MemoryAsStreamTests +{ + private static byte[] _byteSequence = GenerateNonRepeatingByteArray(); + + /// + /// For testing class we need to handle a sequence of bytes where errors are + /// easy to spot. This method generates a sequence of bytes where no 2 byte pairs repeat, which is useful + /// to find errors in the implementation of . + /// + /// Sequence of bytes where no 2 byte pairs repeat + private static byte[] GenerateNonRepeatingByteArray() + { + int byteCount = 256; + int sequenceLength = 65_537; + byte[] result = new byte[sequenceLength]; + + // Start with the first byte being 0 + result[0] = 0; + + // Initialize counters to represent the current pair + byte nextByte = 1; // Start with the second byte being 1 + + // Generate the sequence by sliding over the pairs + for (int i = 1; i < sequenceLength; i++) + { + result[i] = nextByte; + + // Slide the window: move the current byte to the next byte + byte currentByte = nextByte; + + // Determine the next byte (wrap around to avoid repeating pairs) + nextByte = (byte)((nextByte + 1) % byteCount); + + // Ensure we don't repeat consecutive pairs + if (i > 1 && result[i - 1] == currentByte && result[i] == nextByte) + { + // Adjust the next byte to avoid consecutive pair repetition + nextByte = (byte)((nextByte + 1) % byteCount); + } + } + + return result; + } + + [Fact] + public void Read_WithValidInput_ShouldReadBytes() + { + // Arrange + byte[] bytes = _byteSequence; + MemoryAsStream stream = new MemoryAsStream(bytes); + byte[] buffer = new byte[bytes.Length]; + + // Act + int bytesRead = stream.Read(buffer, 0, buffer.Length); + + // Assert + Assert.Equal(bytes.Length, bytesRead); + Assert.Equal(bytes, buffer); + } + + [Fact] + public void Read_ChunkedReads_ShouldReadBytesInChunks() + { + // Arrange + byte[] bytes = _byteSequence; + MemoryAsStream stream = new MemoryAsStream(bytes); + int bytesRead = 0; + + // Act + using var chunkedReader = new BinaryReader(stream); + do + { + byte read = chunkedReader.ReadByte(); + bytes[bytesRead].Should().Be(read, $"Mismatch at position {bytesRead}"); + } while (++bytesRead < bytes.Length); + } + + [Theory] + // Comment out a few cases to reduce the number of tests + [InlineData(2)] + // [InlineData(3)] + // [InlineData(5)] + [InlineData(7)] + // [InlineData(11)] + [InlineData(13)] + // [InlineData(17)] + // [InlineData(19)] + [InlineData(23)] + public void Read_WithChunkSize_ShouldReadBytesInChunks(int chunkSize) + { + // Arrange + byte[] bytes = _byteSequence; + MemoryAsStream stream = new MemoryAsStream(bytes); + byte[] buffer = new byte[chunkSize]; + int bytesRead = 0; + + // Act + while (bytesRead < bytes.Length) + { + int read = stream.Read(buffer, 0, buffer.Length); + read.Should().BeLessOrEqualTo(chunkSize); + for (int i = 0; i < read; i++) + { + bytes[bytesRead].Should().Be(buffer[i], $"Mismatch at position {bytesRead}"); + bytesRead++; + } + } + } +} diff --git a/test/Altinn.App.Core.Tests/Helpers/ObjectUtils_XmlSerializationTests.cs b/test/Altinn.App.Core.Tests/Helpers/ObjectUtils_XmlSerializationTests.cs index 55a682ac4..573028cb9 100644 --- a/test/Altinn.App.Core.Tests/Helpers/ObjectUtils_XmlSerializationTests.cs +++ b/test/Altinn.App.Core.Tests/Helpers/ObjectUtils_XmlSerializationTests.cs @@ -134,29 +134,29 @@ public void TestPrepareForStorage(string? value, string? storedValue) [Theory] [MemberData(nameof(StringTests))] - public async Task TestSerializeDeserializeAsStorage(string? value, string? storedValue) + public void TestSerializeDeserializeAsStorage(string? value, string? storedValue) { var test = CreateObject(value); // Serialize and deserialize twice to ensure that all changes in serialization is applied - var testResult = await SerializeDeserialize(test); - testResult = await SerializeDeserialize(testResult); + var testResult = SerializeDeserialize(test); + testResult = SerializeDeserialize(testResult); AssertObject(testResult, value, storedValue); } - private async Task SerializeDeserialize(YttersteObjekt test) + private YttersteObjekt SerializeDeserialize(YttersteObjekt test) { // Serialize using var serializationStream = new MemoryStream(); - DataClient.Serialize(test, typeof(YttersteObjekt), serializationStream); + var modelSerializer = new ModelSerializationService(null!); + var serialized = modelSerializer.SerializeToXml(test); serializationStream.Seek(0, SeekOrigin.Begin); - _output.WriteLine(Encoding.UTF8.GetString(serializationStream.ToArray())); + _output.WriteLine(Encoding.UTF8.GetString(serialized.Span)); // Deserialize - ModelDeserializer serializer = new ModelDeserializer(_loggerMock.Object, typeof(YttersteObjekt)); - var deserialized = await serializer.DeserializeAsync(serializationStream, "application/xml"); + var deserialized = modelSerializer.DeserializeXml(serialized.Span, typeof(YttersteObjekt)); var testResult = deserialized.Should().BeOfType().Which; return testResult; @@ -267,13 +267,13 @@ public void TestPrepareForStorage_Decimal(decimal? value) [Theory] [MemberData(nameof(DecimalTests))] - public async Task TestSerializeDeserializeAsStorage_Decimal(decimal? value) + public void TestSerializeDeserializeAsStorage_Decimal(decimal? value) { var test = CreateObject(value); // Serialize and deserialize twice to ensure that all changes in serialization is applied - var testResult = await SerializeDeserialize(test); - testResult = await SerializeDeserialize(testResult); + var testResult = SerializeDeserialize(test); + testResult = SerializeDeserialize(testResult); AssertObject(testResult, value); } diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index b79015b0b..2d0bd0325 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -5,7 +5,9 @@ using Altinn.App.Common.Tests; using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Infrastructure.Clients.Storage; +using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Models; using Altinn.App.Core.Tests.Infrastructure.Clients.Storage.TestData; @@ -24,6 +26,7 @@ public class DataClientTests { private readonly Mock> platformSettingsOptions; private readonly Mock userTokenProvide; + private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly ILogger logger; private readonly string apiStorageEndpoint = "https://local.platform.altinn.no/api/storage/"; @@ -646,7 +649,12 @@ await dataClient.UpdateData( [Fact] public async Task UpdateData_throws_error_if_serilization_fails() { - object exampleModel = new ExampleModel() { Name = "Test", Age = 22 }; + object exampleModel = new ExampleModel() + { + Name = "Test", + Age = 22, + ShouldError = true + }; var instanceIdentifier = new InstanceIdentifier("501337/d3f3250d-705c-4683-a215-e05ebcbe6071"); var dataGuid = new Guid("67a5ef12-6e38-41f8-8b42-f91249ebcec0"); int invocations = 0; @@ -843,6 +851,8 @@ private DataClient GetDataClient( logger, new HttpClient(delegatingHandlerStub), userTokenProvide.Object, + null!, + new ModelSerializationService(_appModelMock.Object), telemetrySink?.Object ); } diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/TestData/ExampleModel.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/TestData/ExampleModel.cs index 46f55c0bc..2c2a70b31 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/TestData/ExampleModel.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/TestData/ExampleModel.cs @@ -15,4 +15,12 @@ public class ExampleModel /// The age /// public int Age { get; set; } = 0; + + public bool ShouldError { get; set; } = false; + + public string Error + { + get { return ShouldError ? throw new Exception() : string.Empty; } + set { } + } } diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 0b270c547..e9de29226 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -2,6 +2,7 @@ using Altinn.App.Common.Tests; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -52,6 +53,7 @@ public sealed class PatchServiceTests : IDisposable // System under test private readonly PatchService _patchService; + private readonly ModelSerializationService _modelSerializationService; public PatchServiceTests() { @@ -71,14 +73,12 @@ public PatchServiceTests() _dataElementValidator.Setup(dev => dev.ValidationSource).Returns("dataElementValidator"); _dataClientMock .Setup(d => - d.UpdateData( - It.IsAny(), - It.IsAny(), - It.IsAny(), + d.UpdateBinaryData( + It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny() + It.IsAny(), + It.IsAny() ) ) .ReturnsAsync(_dataElement) @@ -94,12 +94,13 @@ public PatchServiceTests() ); var validationService = new ValidationService(validatorFactory, _vLoggerMock.Object); + _modelSerializationService = new ModelSerializationService(_appModelMock.Object); _patchService = new PatchService( _appMetadataMock.Object, _dataClientMock.Object, validationService, new List { _dataProcessorMock.Object }, - _appModelMock.Object, + _modelSerializationService, _telemetrySink.Object ); } @@ -114,7 +115,7 @@ public PatchServiceTests() private static readonly DataElement _dataElement = new() { Id = _dataGuid.ToString(), DataType = _dataType.Id }; - private class MyModel + public class MyModel { [MinLength(20)] public string? Name { get; set; } @@ -126,19 +127,7 @@ public async Task Test_Ok() JsonPatch jsonPatch = new JsonPatch(PatchOperation.Replace(JsonPointer.Parse("/Name"), "Test Testesen")); List ignoredValidators = new List { "required" }; var oldModel = new MyModel { Name = "OrginaltNavn" }; - _dataClientMock - .Setup(d => - d.GetFormData( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(oldModel) - .Verifiable(); + SetupDataClient(oldModel); var validationIssues = new List() { new() { Severity = ValidationIssueSeverity.Error, Description = "First error", } @@ -182,7 +171,7 @@ public async Task Test_Ok() .BeOfType() .Which; change.DataElement.Id.Should().Be(_dataGuid.ToString()); - change.CurrentValue.Should().BeOfType().Subject.Name.Should().Be("Test Testesen"); + change.CurrentFormData.Should().BeOfType().Subject.Name.Should().Be("Test Testesen"); var validator = res.ValidationIssues.Should().ContainSingle().Which; validator.Key.Should().Be("formDataValidator"); var issue = validator.Value.Should().ContainSingle().Which; @@ -203,19 +192,7 @@ public async Task Test_JsonPatchTest_fail() ); List ignoredValidators = new List { "required" }; var oldModel = new MyModel { Name = "OrginaltNavn" }; - _dataClientMock - .Setup(d => - d.GetFormData( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(oldModel) - .Verifiable(); + SetupDataClient(oldModel); var validationIssues = new List() { new() { Severity = ValidationIssueSeverity.Error, Description = "First error", } @@ -265,19 +242,7 @@ public async Task Test_JsonPatch_does_not_deserialize() JsonPatch jsonPatch = new JsonPatch(PatchOperation.Add(JsonPointer.Parse("/Age"), 1)); List ignoredValidators = new List { "required" }; var oldModel = new MyModel { Name = "OrginaltNavn" }; - _dataClientMock - .Setup(d => - d.GetFormData( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny() - ) - ) - .ReturnsAsync(oldModel) - .Verifiable(); + SetupDataClient(oldModel); var validationIssues = new List() { new() { Severity = ValidationIssueSeverity.Error, Description = "First error", } @@ -321,6 +286,22 @@ public async Task Test_JsonPatch_does_not_deserialize() err.ErrorType.Should().Be(DataPatchErrorType.DeserializationFailed); } + private void SetupDataClient(MyModel oldModel) + { + _dataClientMock + .Setup(d => + d.GetDataBytes( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(_modelSerializationService.SerializeToXml(oldModel).ToArray()) + .Verifiable(); + } + public void Dispose() { _telemetrySink.Dispose(); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index 661273c1b..e67ab0996 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -247,21 +248,21 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew { _resources.Setup(r => r.GetLayoutSetForTask("Task_1")).Returns(layoutSet); var appMetadata = new ApplicationMetadata("ttd/test-app") { DataTypes = dataTypes }; + var modelSerializationService = new ModelSerializationService(_appModel.Object); _appMetadata.Setup(m => m.GetApplicationMetadata()).ReturnsAsync(appMetadata).Verifiable(Times.AtLeastOnce); if (formData != null) { _dataClient .Setup(d => - d.GetFormData( - It.IsAny(), - It.IsAny(), + d.GetDataBytes( It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny() ) ) - .ReturnsAsync(formData); + .ReturnsAsync(modelSerializationService.SerializeToXml(formData).ToArray()); _appModel.Setup(am => am.GetModelType(_classRef)).Returns(formData.GetType()); } @@ -272,7 +273,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew instance, _dataClient.Object, _appMetadata.Object, - _appModel.Object + modelSerializationService ); var layoutStateInit = new LayoutEvaluatorStateInitializer( diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index 2ba546a1b..f9ffa5fec 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -1,4 +1,5 @@ using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -277,7 +278,7 @@ IEnumerable gatewayFilters new NullLogger(), _dataClient.Object, _appMetadata.Object, - _appModel.Object + new ModelSerializationService(_appModel.Object) ); } } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs index 317447f86..f03c5fc28 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs @@ -1,4 +1,5 @@ using Altinn.App.Core.Configuration; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; @@ -26,7 +27,7 @@ public ProcessTaskFinalizerTests() _processTaskFinalizer = new ProcessTaskFinalizer( _appMetadataMock.Object, _dataClientMock.Object, - _appModelMock.Object, + new ModelSerializationService(_appModelMock.Object), _layoutEvaluatorStateInitializerMock.Object, _appSettings ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 483f4cb88..a7ca575c7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -78,14 +78,19 @@ public void Add(DataElement? dataElement, object data, int maxCount = 1) public Instance Instance { get; } - public Task GetData(DataElementId dataElementId) + public Task GetFormData(DataElementId dataElementId) { return Task.FromResult(_dataById[dataElementId]); } - public Task GetSingleDataByType(string dataType) + public Task> GetBinaryData(DataElementId dataElementId) { - return Task.FromResult(_dataByType.GetValueOrDefault(dataType)); + throw new NotImplementedException(); + } + + public DataElement GetDataElement(DataElementId dataElementId) + { + throw new NotImplementedException(); } public IEnumerator> GetEnumerator() From fdc4cb4873e0847bc2b23d2f1e0a638b89ddc08a Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 30 Sep 2024 08:29:12 +0200 Subject: [PATCH 47/63] Add draft support for adding/removing/modifying other data elements in dataProcessWrite --- .../Controllers/DataController.cs | 1 + .../Models/DataPatchResponseMultiple.cs | 6 + .../Features/IDataProcessor.cs | 7 + .../Features/IInstanceDataAccessor.cs | 24 ++++ .../Data/CachedInstanceDataAccessor.cs | 126 +++++++++++++++++- .../LayoutEvaluatorStateInitializer.cs | 28 ++++ .../Internal/Patch/DataPatchResult.cs | 6 + .../Internal/Patch/PatchService.cs | 9 ++ .../Altinn.App.Api.Tests/OpenApi/swagger.json | 4 + .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 3 + .../Clients/Storage/DataClientTests.cs | 3 +- .../TestUtilities/InstanceDataAccessorFake.cs | 20 +++ 12 files changed, 234 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index eb8dcd7b6..99e1cd53a 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -591,6 +591,7 @@ await UpdatePresentationTextsOnInstance( return Ok( new DataPatchResponseMultiple() { + Instance = res.Ok.Instance, NewDataModels = res.Ok.UpdatedData, ValidationIssues = res.Ok.ValidationIssues } diff --git a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs index dd64c5352..90314ec6a 100644 --- a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs +++ b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs @@ -1,5 +1,6 @@ using Altinn.App.Api.Controllers; using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Api.Models; @@ -17,4 +18,9 @@ public class DataPatchResponseMultiple /// The current data in all data models updated by the patch operation. /// public required Dictionary NewDataModels { get; init; } + + /// + /// The instance with updated dataElement list. + /// + public required Instance Instance { get; init; } } diff --git a/src/Altinn.App.Core/Features/IDataProcessor.cs b/src/Altinn.App.Core/Features/IDataProcessor.cs index d8fa5a004..4265706ac 100644 --- a/src/Altinn.App.Core/Features/IDataProcessor.cs +++ b/src/Altinn.App.Core/Features/IDataProcessor.cs @@ -25,4 +25,11 @@ public interface IDataProcessor /// The previous data model (for running comparisons) /// The currently selected language of the user (if available) public Task ProcessDataWrite(Instance instance, Guid? dataId, object data, object? previousData, string? language); + + public Task ProcessDataWrite( + IInstanceDataAccessor instanceDataAccessor, + string taskId, + List changes, + string? language + ) => Task.CompletedTask; } diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index ecbeb20c1..1b1f0c0a6 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -32,4 +32,28 @@ public interface IInstanceDataAccessor /// /// Throws an InvalidOperationException if the data element is not found on the instance DataElement GetDataElement(DataElementId dataElementId); + + /// + /// Add a new data element with app logic to the instance of this accessor + /// + /// + /// Serialization of data is done immediately, so the data object should be in a valid state. + /// + /// Throws an InvalidOperationException if the dataType is not found in applicationmetadata + void AddFormDataElement(string dataType, object data); + + /// + /// Add a new data element without app logic to the instance. + /// + /// + /// Saving to storage is not done until the instance is saved, so mutations to data might or might not be sendt to storage. + /// + void AddAttachmentDataElement(string dataType, string contentType, string? filename, ReadOnlyMemory bytes); + + /// + /// Remove a data element from the instance. + /// + /// Actual removal from storage is not done until the instance is saved. + /// + void RemoveDataElement(DataElementId dataElementId); } diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index c9153a432..5b12b97cc 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Globalization; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; @@ -24,6 +25,13 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor private readonly ModelSerializationService _modelSerializationService; private readonly LazyCache _formDataCache = new(); private readonly LazyCache> _binaryCache = new(); + private readonly ConcurrentBag _dataElementsToDelete = new(); + private readonly ConcurrentBag<( + DataType dataType, + string contentType, + string? filename, + ReadOnlyMemory bytes + )> _dataElementsToAdd = new(); public CachedInstanceDataAccessor( Instance instance, @@ -87,6 +95,73 @@ public DataType GetDataType(DataElementId dataElementId) return dataType; } + /// + public void AddFormDataElement(string dataTypeString, object data) + { + var dataType = GetDataTypeByString(dataTypeString).Result; + if (dataType.AppLogic?.ClassRef is not { } classRef) + { + throw new InvalidOperationException( + $"Data type {dataTypeString} does not have a class reference in app metadata" + ); + } + + var modelType = data.GetType(); + if (modelType.FullName != classRef) + { + throw new InvalidOperationException( + $"Data object registered for {dataTypeString} is not of type {classRef} as specified in application metadata" + ); + } + + var (bytes, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); + + _dataElementsToAdd.Add((dataType, contentType, null, bytes)); + // var dataElement = await _dataClient.InsertBinaryData( + // Instance.Id, + // dataTypeString, + // contentType, + // null, + // new MemoryAsStream(binaryData) + // ); + // Instance.Data.Add(dataElement); + // + // return dataElement; + } + + /// + public void AddAttachmentDataElement( + string dataTypeString, + string contentType, + string? filename, + ReadOnlyMemory bytes + ) + { + var dataType = GetDataTypeByString(dataTypeString).Result; + if (dataType.AppLogic?.ClassRef is not null) + { + throw new InvalidOperationException( + $"Data type {dataTypeString} has a AppLogic.ClassRef in app metadata, and is not a binary data element" + ); + } + _dataElementsToAdd.Add((dataType, contentType, filename, bytes)); + } + + /// + public void RemoveDataElement(DataElementId dataElementId) + { + var idAsString = dataElementId.ToString(); + var dataElement = Instance.Data.Find(d => d.Id == idAsString); + if (dataElement is null) + { + throw new InvalidOperationException($"Data element with id {idAsString} not found in instance"); + } + //TODO: Add to list of data elements to delete + // await _dataClient.DeleteData(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Guid, true); + + Instance.Data.Remove(dataElement); + } + public List GetDataElementChanges() { var changes = new List(); @@ -124,9 +199,56 @@ public List GetDataElementChanges() return changes; } - internal Task UpdateInstanceData() + internal async Task UpdateInstanceData() { - return Task.CompletedTask; + var tasks = new List(); + ConcurrentBag createdDataElements = new(); + // We need to create data elements here, so that we can set them correctly on the instance + // Updating and deleting is done in SaveChanges and happen in parallel with validation. + + // Upload added data elements + foreach (var (dataType, contentType, filename, bytes) in _dataElementsToAdd) + { + async Task InsertBinaryData() + { + var dataElement = await _dataClient.InsertBinaryData( + Instance.Id, + dataType.Id, + contentType, + filename, + new MemoryAsStream(bytes) + ); + createdDataElements.Add(dataElement); + } + + tasks.Add(InsertBinaryData()); + } + + // Delete data elements + foreach (var dataElementId in _dataElementsToDelete) + { + async Task DeleteData() + { + await _dataClient.DeleteData( + _org, + _app, + _instanceOwnerPartyId, + _instanceGuid, + dataElementId.Guid, + true + ); + } + + tasks.Add(DeleteData()); + } + + await Task.WhenAll(tasks); + + // Remove deleted data elements from instance.Data + Instance.Data.RemoveAll(dataElement => _dataElementsToDelete.Any(d => d.Id == dataElement.Id)); + + // Add Created data elements to instance + Instance.Data.AddRange(createdDataElements); } internal async Task SaveChanges(List changes, bool initializeRowId) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index cf8b86d12..8f58e8217 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -80,6 +80,34 @@ public DataElement GetDataElement(DataElementId dataElementId) } return _dataElement; } + + // Not implemented + public void AddFormDataElement(string dataType, object data) + { + throw new NotImplementedException( + "The obsolete LayoutEvaluatorStateInitializer.Init method does not support adding data elements" + ); + } + + public void AddAttachmentDataElement( + string dataType, + string contentType, + string? filename, + ReadOnlyMemory data + ) + { + throw new NotImplementedException( + "The obsolete LayoutEvaluatorStateInitializer.Init method does not support adding data elements" + ); + } + + // Not implemented + public void RemoveDataElement(DataElementId dataElementId) + { + throw new NotImplementedException( + "The obsolete LayoutEvaluatorStateInitializer.Init method does not support removing data elements" + ); + } } /// diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index 0553ef2c9..a63ef2c64 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -1,5 +1,6 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Internal.Patch; @@ -8,6 +9,11 @@ namespace Altinn.App.Core.Internal.Patch; /// public class DataPatchResult { + /// + /// The updated instance after the patch and dataProcessing operations. + /// + public required Instance Instance { get; set; } + /// /// The validation issues that were found during the patch operation. /// diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index fa9a8a050..efc9e4c14 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -152,6 +152,14 @@ await dataProcessor.ProcessDataWrite( throw; } } + + // TODO: add new method to IDataProcessor that takes multiple models at the same time + await dataProcessor.ProcessDataWrite( + dataAccessor, + instance.Process.CurrentTask.ElementId, + changesAfterPatch, + language + ); } // Get all changes to data elements by comparing the serialized values @@ -197,6 +205,7 @@ await dataProcessor.ProcessDataWrite( return new DataPatchResult { + Instance = instance, ChangedDataElements = changes, UpdatedData = updatedData, ValidationIssues = validationIssues, diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index e1f7569bb..9363a8820 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -5728,6 +5728,7 @@ }, "DataPatchResponseMultiple": { "required": [ + "instance", "newDataModels", "validationIssues" ], @@ -5747,6 +5748,9 @@ "type": "object", "additionalProperties": { }, "nullable": true + }, + "instance": { + "$ref": "#/components/schemas/Instance" } }, "additionalProperties": false diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index c1db9ad5f..d7a88f22e 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -3580,6 +3580,7 @@ components: additionalProperties: false DataPatchResponseMultiple: required: + - instance - newDataModels - validationIssues type: object @@ -3595,6 +3596,8 @@ components: type: object additionalProperties: { } nullable: true + instance: + $ref: '#/components/schemas/Instance' additionalProperties: false DataType: type: object diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index 2d0bd0325..450e69979 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Reflection; using System.Text; using Altinn.App.Common.Tests; using Altinn.App.Core.Configuration; @@ -666,7 +667,7 @@ public async Task UpdateData_throws_error_if_serilization_fails() return new HttpResponseMessage() { StatusCode = HttpStatusCode.OK }; } ); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( async () => await dataClient.UpdateData( exampleModel, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index a7ca575c7..3650599d8 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -93,6 +93,26 @@ public DataElement GetDataElement(DataElementId dataElementId) throw new NotImplementedException(); } + public void AddFormDataElement(string dataType, object data) + { + throw new NotImplementedException(); + } + + public void AddAttachmentDataElement( + string dataType, + string contentType, + string? filename, + ReadOnlyMemory data + ) + { + throw new NotImplementedException(); + } + + public void RemoveDataElement(DataElementId dataElementId) + { + throw new NotImplementedException(); + } + public IEnumerator> GetEnumerator() { // We implement IEnumerable so that we can use the collection initializer syntax, but we also store the elements for debugger visualization From 2e4cb529c2e359ef2fc29ce7d025f60f73fe7151 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 30 Sep 2024 10:11:56 +0200 Subject: [PATCH 48/63] Code review fixes --- .../Serialization/ModelDeserializer.cs | 2 +- .../Data/CachedInstanceDataAccessor.cs | 22 +++++-------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs index 3210effa5..017c292e7 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelDeserializer.cs @@ -10,7 +10,7 @@ namespace Altinn.App.Core.Helpers.Serialization; /// Represents logic to deserialize a stream of data to an instance of the given type /// // [Obsolete( -// "This class is deprecated and will be removed in a v9. Use Altinn.App.PlatformServices.Helpers.Serialization.ModelSerializationSerivce from dependency injection instead." +// "This class is deprecated and will be removed in a v9. Use Altinn.App.Core.Helpers.Serialization.ModelSerializationSerivce from dependency injection instead." // )] public class ModelDeserializer { diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index 5b12b97cc..fc93e6949 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -98,7 +98,7 @@ public DataType GetDataType(DataElementId dataElementId) /// public void AddFormDataElement(string dataTypeString, object data) { - var dataType = GetDataTypeByString(dataTypeString).Result; + var dataType = GetDataTypeByString(dataTypeString); if (dataType.AppLogic?.ClassRef is not { } classRef) { throw new InvalidOperationException( @@ -117,16 +117,6 @@ public void AddFormDataElement(string dataTypeString, object data) var (bytes, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); _dataElementsToAdd.Add((dataType, contentType, null, bytes)); - // var dataElement = await _dataClient.InsertBinaryData( - // Instance.Id, - // dataTypeString, - // contentType, - // null, - // new MemoryAsStream(binaryData) - // ); - // Instance.Data.Add(dataElement); - // - // return dataElement; } /// @@ -137,7 +127,7 @@ public void AddAttachmentDataElement( ReadOnlyMemory bytes ) { - var dataType = GetDataTypeByString(dataTypeString).Result; + var dataType = GetDataTypeByString(dataTypeString); if (dataType.AppLogic?.ClassRef is not null) { throw new InvalidOperationException( @@ -156,10 +146,8 @@ public void RemoveDataElement(DataElementId dataElementId) { throw new InvalidOperationException($"Data element with id {idAsString} not found in instance"); } - //TODO: Add to list of data elements to delete - // await _dataClient.DeleteData(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Guid, true); - Instance.Data.Remove(dataElement); + _dataElementsToDelete.Add(dataElementId); } public List GetDataElementChanges() @@ -337,9 +325,9 @@ public void Set(DataElementId key, T data) } } - private async Task GetDataTypeByString(string dataTypeString) + private DataType GetDataTypeByString(string dataTypeString) { - var appMetadata = await _appMetadata.GetApplicationMetadata(); + var appMetadata = _appMetadata.GetApplicationMetadata().Result; var dataType = appMetadata.DataTypes.Find(d => d.Id == dataTypeString); if (dataType is null) { From c77239f09f7bd95289285db521281f4a054d50d7 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 14:28:29 +0200 Subject: [PATCH 49/63] Various cleanup tasks * Use IInstanceDataMutator for actions * Return List instead of dictionary for models and validation issues in MultipleDataPatch for improved swagger doc * New IDataWriteProcessor interface with IInstanceDataMutator and change list to mimik IValidator * IValidator gets ShouldRunForTask(string taskId) so that * Required validators only runs for tasks with layout. * Expression validators only runs for tasks with datatypes that has expressions validation files * Revert DataElementIdentifier to a simple wrapper for Guid (that caches the string version) --- .../Controllers/ActionsController.cs | 104 +++++------ .../Controllers/DataController.cs | 12 +- .../Models/DataPatchResponseMultiple.cs | 11 +- .../Features/IDataProcessor.cs | 7 - .../Features/IDataWriteProcessor.cs | 24 +++ .../Features/IInstanceDataAccessor.cs | 24 --- .../Features/IInstanceDataMutator.cs | 34 ++++ src/Altinn.App.Core/Features/IValidator.cs | 23 ++- .../Features/Telemetry/Telemetry.Data.cs | 6 + .../Validation/Default/ExpressionValidator.cs | 57 ++++-- .../Validation/Default/RequiredValidator.cs | 16 +- src/Altinn.App.Core/Helpers/MemoryAsStream.cs | 3 + .../ModelSerializationService.cs | 12 +- .../Implementation/AppResourcesSI.cs | 16 +- .../Internal/App/IAppResources.cs | 2 +- .../Data/CachedInstanceDataAccessor.cs | 105 +++++++---- .../LayoutEvaluatorStateInitializer.cs | 2 +- .../Internal/Patch/DataPatchResult.cs | 12 +- .../Internal/Patch/PatchService.cs | 77 +++++--- .../Internal/Process/ProcessEngine.cs | 32 +++- .../Internal/Validation/IValidationService.cs | 2 +- .../Internal/Validation/IValidatorFactory.cs | 7 +- .../Internal/Validation/ValidationService.cs | 17 +- src/Altinn.App.Core/Models/DataElementId.cs | 67 +++++-- .../Models/UserAction/UserActionContext.cs | 33 ++++ .../Models/UserAction/UserActionResult.cs | 2 +- .../Validation/ValidationIssueWithSource.cs | 7 + .../Controllers/ActionsControllerTests.cs | 14 +- .../Controllers/DataController_PatchTests.cs | 40 +++-- ...dator_ReturnsValidationErrors.verified.txt | 35 ++-- ...sNext_PdfFails_DataIsUnlocked.verified.txt | 35 ++-- .../Mocks/DataClientMock.cs | 22 +-- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 48 ++++- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 35 +++- .../Default/ExpressionValidatorTests.cs | 15 +- .../ValidationServiceTests.cs | 19 +- ...lidatorFunctionForIncremental.verified.txt | 27 +-- .../Validators/ValidationServiceTests.cs | 17 +- .../Internal/Patch/PatchServiceTests.cs | 12 +- ...ocess_and_moves_to_first_task.verified.txt | 17 +- .../Internal/Process/ProcessEngineTest.cs | 164 ++++++++++++++---- .../FullTests/SubForm/SubFormTests.cs | 16 ++ .../TestUtilities/InstanceDataAccessorFake.cs | 2 +- 43 files changed, 838 insertions(+), 394 deletions(-) create mode 100644 src/Altinn.App.Core/Features/IDataWriteProcessor.cs create mode 100644 src/Altinn.App.Core/Features/IInstanceDataMutator.cs diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index 64c2b56de..0f4a7010c 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -3,7 +3,6 @@ using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; -using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; @@ -65,7 +64,7 @@ ModelSerializationService modelSerialization /// /// Perform a task action on an instance /// - /// unique identfier of the organisation responsible for the app + /// unique identifier of the organisation responsible for the app /// application identifier which is unique within an organisation /// unique id of the party that this the owner of the instance /// unique id to identify the instance @@ -131,8 +130,9 @@ public async Task> Perform( return Forbid(); } + var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); UserActionContext userActionContext = - new(instance, userId.Value, actionRequest.ButtonId, actionRequest.Metadata, language); + new(dataAccessor, userId.Value, actionRequest.ButtonId, actionRequest.Metadata, language); IUserAction? actionHandler = _userActionService.GetActionHandler(action); if (actionHandler == null) { @@ -164,78 +164,60 @@ public async Task> Perform( ); } - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); - Dictionary>>? validationIssues = null; - + // If the action handler returns UpdatedDataModels, instead of using the dataMutator + // we need to update the dataAccessor with the new data in case it was fetched with DataClient +#pragma warning disable CS0618 // Type or member is obsolete if (result.UpdatedDataModels is { Count: > 0 }) { - var changes = await SaveChangedModels(instance, dataAccessor, result.UpdatedDataModels); + foreach (var (elementId, newModel) in result.UpdatedDataModels) + { + if (newModel is null) + { + continue; + } - validationIssues = await GetValidations( - instance, - dataAccessor, - changes, - actionRequest.IgnoredValidators, - language - ); + var dataElement = + instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException( + $"Action handler {actionHandler.GetType().Name} returned an updated data model for a data element that does not exist: {elementId}" + ); + + // update dataAccessor to use the changed data + dataAccessor.ReplaceFormDataAssumeSavedToStorage(dataElement, newModel); + } } +#pragma warning restore CS0618 // Type or member is obsolete + + var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: true); + + await dataAccessor.UpdateInstanceData(); + + var saveTask = dataAccessor.SaveChanges(changes); + + var validationIssues = await GetIncrementalValidations( + instance, + dataAccessor, + changes, + actionRequest.IgnoredValidators, + language + ); + await saveTask; return Ok( new UserActionResponse() { ClientActions = result.ClientActions, - UpdatedDataModels = result.UpdatedDataModels, + UpdatedDataModels = changes.ToDictionary(c => c.DataElement.Id, c => c.CurrentFormData), UpdatedValidationIssues = validationIssues, RedirectUrl = result.RedirectUrl, } ); } - private async Task> SaveChangedModels( - Instance instance, - CachedInstanceDataAccessor dataAccessor, - Dictionary resultUpdatedDataModels - ) - { - var changes = new List(); - var instanceIdentifier = new InstanceIdentifier(instance); - foreach (var (elementId, newModel) in resultUpdatedDataModels) - { - if (newModel is null) - { - continue; - } - var dataElement = instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)); - var previousData = await dataAccessor.GetFormData(dataElement); - - ObjectUtils.InitializeAltinnRowId(newModel); - ObjectUtils.PrepareModelForXmlStorage(newModel); - - await _dataClient.UpdateData( - newModel, - instanceIdentifier.InstanceGuid, - newModel.GetType(), - instance.Org, - instance.AppId.Split('/')[1], - instanceIdentifier.InstanceOwnerPartyId, - Guid.Parse(dataElement.Id) - ); - // update dataAccessor to use the changed data - dataAccessor.SetFormData(dataElement, newModel); - // add change to list - changes.Add( - new DataElementChange - { - DataElement = dataElement, - PreviousFormData = previousData, - CurrentFormData = newModel, - } - ); - } - return changes; - } - - private async Task>>?> GetValidations( + private async Task> + >?> GetIncrementalValidations( Instance instance, IInstanceDataAccessor dataAccessor, List changes, @@ -261,7 +243,7 @@ await _dataClient.UpdateData( private static Dictionary< string, Dictionary> - > PartitionValidationIssuesByDataElement(Dictionary> validationIssues) + > PartitionValidationIssuesByDataElement(List validationIssues) { var updatedValidationIssues = new Dictionary>>(); foreach (var (validationSource, issuesFromSource) in validationIssues) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 99e1cd53a..e7b840cb5 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -455,6 +455,7 @@ public async Task Put( [ProducesResponseType(typeof(DataPatchResponse), 200)] [ProducesResponseType(typeof(ProblemDetails), 409)] [ProducesResponseType(typeof(ProblemDetails), 422)] + [Obsolete("Use PatchFormDataMultiple instead")] public async Task> PatchFormData( [FromRoute] string org, [FromRoute] string app, @@ -478,8 +479,8 @@ public async Task> PatchFormData( return Ok( new DataPatchResponse() { - ValidationIssues = newResponse.ValidationIssues, - NewDataModel = newResponse.NewDataModels[dataGuid], + ValidationIssues = newResponse.ValidationIssues.ToDictionary(d => d.Source, d => d.Issues), + NewDataModel = newResponse.NewDataModels.First(m => m.Id == dataGuid).Data, } ); } @@ -592,7 +593,12 @@ await UpdatePresentationTextsOnInstance( new DataPatchResponseMultiple() { Instance = res.Ok.Instance, - NewDataModels = res.Ok.UpdatedData, + NewDataModels = res + .Ok.UpdatedData.Select(d => new DataPatchResponseMultiple.DataModelPairResponse( + d.Id.Guid, + d.Data + )) + .ToList(), ValidationIssues = res.Ok.ValidationIssues } ); diff --git a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs index 90314ec6a..cc3a669f0 100644 --- a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs +++ b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs @@ -12,12 +12,19 @@ public class DataPatchResponseMultiple /// /// The validation issues that were found during the patch operation. /// - public required Dictionary> ValidationIssues { get; init; } + public required List ValidationIssues { get; init; } /// /// The current data in all data models updated by the patch operation. /// - public required Dictionary NewDataModels { get; init; } + public required List NewDataModels { get; init; } + + /// + /// Pair of Guid and data object. + /// + /// The guid of the DataElement + /// The form data of the data element + public record DataModelPairResponse(Guid Id, object Data); /// /// The instance with updated dataElement list. diff --git a/src/Altinn.App.Core/Features/IDataProcessor.cs b/src/Altinn.App.Core/Features/IDataProcessor.cs index 4265706ac..d8fa5a004 100644 --- a/src/Altinn.App.Core/Features/IDataProcessor.cs +++ b/src/Altinn.App.Core/Features/IDataProcessor.cs @@ -25,11 +25,4 @@ public interface IDataProcessor /// The previous data model (for running comparisons) /// The currently selected language of the user (if available) public Task ProcessDataWrite(Instance instance, Guid? dataId, object data, object? previousData, string? language); - - public Task ProcessDataWrite( - IInstanceDataAccessor instanceDataAccessor, - string taskId, - List changes, - string? language - ) => Task.CompletedTask; } diff --git a/src/Altinn.App.Core/Features/IDataWriteProcessor.cs b/src/Altinn.App.Core/Features/IDataWriteProcessor.cs new file mode 100644 index 000000000..b59b70bda --- /dev/null +++ b/src/Altinn.App.Core/Features/IDataWriteProcessor.cs @@ -0,0 +1,24 @@ +namespace Altinn.App.Core.Features; + +/// +/// This interface defines how you can make changes to the data model on every write operation from frontend. +/// +public interface IDataWriteProcessor +{ + /// + /// Is called to run custom calculation events defined by app developer when data is written to app. + /// + /// + /// Make changes directly to the changes[].CurrentFormData object, or fetch other data elements from the instanceDataMutator. + /// + /// Object to fetch data elements not included in changes + /// The current task ID + /// List of changes that are included in this request(might not include changes to extra data models from previous ) + /// The currently active language or null + Task ProcessDataWrite( + IInstanceDataMutator instanceDataMutator, + string taskId, + List changes, + string? language + ) => Task.CompletedTask; +} diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index 1b1f0c0a6..ecbeb20c1 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -32,28 +32,4 @@ public interface IInstanceDataAccessor /// /// Throws an InvalidOperationException if the data element is not found on the instance DataElement GetDataElement(DataElementId dataElementId); - - /// - /// Add a new data element with app logic to the instance of this accessor - /// - /// - /// Serialization of data is done immediately, so the data object should be in a valid state. - /// - /// Throws an InvalidOperationException if the dataType is not found in applicationmetadata - void AddFormDataElement(string dataType, object data); - - /// - /// Add a new data element without app logic to the instance. - /// - /// - /// Saving to storage is not done until the instance is saved, so mutations to data might or might not be sendt to storage. - /// - void AddAttachmentDataElement(string dataType, string contentType, string? filename, ReadOnlyMemory bytes); - - /// - /// Remove a data element from the instance. - /// - /// Actual removal from storage is not done until the instance is saved. - /// - void RemoveDataElement(DataElementId dataElementId); } diff --git a/src/Altinn.App.Core/Features/IInstanceDataMutator.cs b/src/Altinn.App.Core/Features/IInstanceDataMutator.cs new file mode 100644 index 000000000..a33872eea --- /dev/null +++ b/src/Altinn.App.Core/Features/IInstanceDataMutator.cs @@ -0,0 +1,34 @@ +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features; + +/// +/// Extension of the IInstanceDataAccessor that allows for adding and removing data elements, +/// and also indicate that it is OK to mutate the models +/// +public interface IInstanceDataMutator : IInstanceDataAccessor +{ + /// + /// Add a new data element with app logic to the instance of this accessor + /// + /// + /// Serialization of data is done immediately, so the data object should be in a valid state. + /// + /// Throws an InvalidOperationException if the dataType is not found in applicationmetadata + void AddFormDataElement(string dataType, object model); + + /// + /// Add a new data element without app logic to the instance. + /// + /// + /// Saving to storage is not done until the instance is saved, so mutations to data might or might not be sendt to storage. + /// + void AddAttachmentDataElement(string dataType, string contentType, string? filename, ReadOnlyMemory bytes); + + /// + /// Remove a data element from the instance. + /// + /// Actual removal from storage is not done until the instance is saved. + /// + void RemoveDataElement(DataElementId dataElementId); +} diff --git a/src/Altinn.App.Core/Features/IValidator.cs b/src/Altinn.App.Core/Features/IValidator.cs index 7cfbdc696..12a13eb46 100644 --- a/src/Altinn.App.Core/Features/IValidator.cs +++ b/src/Altinn.App.Core/Features/IValidator.cs @@ -1,4 +1,3 @@ -using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -12,8 +11,16 @@ public interface IValidator /// /// The task id for the task that the validator is associated with or "*" if the validator should run for all tasks. /// + /// Ignored if is implemented public string TaskId { get; } + /// + /// Check if this validator should run for the given task + /// + /// Default implementations check + /// + public bool ShouldRunForTask(string taskId) => TaskId == "*" || TaskId == taskId; + /// /// Unique string that identifies the source of the validation issues from this validator /// Used for incremental validation. Default implementation should typically work. @@ -65,12 +72,12 @@ List changes /// /// Represents a change in a data element with current and previous deserialized data /// -public class DataElementChange +public sealed class DataElementChange { /// /// The data element the change is related to /// - public required DataElementId DataElement { get; init; } + public required DataElement DataElement { get; init; } /// /// The state of the data element before the change @@ -81,4 +88,14 @@ public class DataElementChange /// The state of the data element after the change /// public required object CurrentFormData { get; init; } + + /// + /// The binary representation (for storage) of the data element before changes + /// + public ReadOnlyMemory? PreviousBinaryData { get; init; } + + /// + /// The binary representation (for storage) of the data element after changes + /// + public ReadOnlyMemory? CurrentBinaryData { get; init; } } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Data.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Data.cs index b17b57708..c98550c08 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Data.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Data.cs @@ -38,6 +38,12 @@ internal void DataPatched(PatchResult result) => return activity; } + internal Activity? StartDataProcessWriteActivity(IDataWriteProcessor dataProcessor) + { + var activity = ActivitySource.StartActivity($"{Prefix}.ProcessWrite.{dataProcessor.GetType().Name}"); + return activity; + } + internal static class Data { internal const string Prefix = "Data"; diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index cc9b67080..255684178 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -40,9 +40,16 @@ IAppMetadata appMetadata _appMetadata = appMetadata; } - /// + /// + /// We implement instead + /// public string TaskId => "*"; + /// + /// Only run for tasks that specifies a layout set + /// + public bool ShouldRunForTask(string taskId) => GetDataTypesWithExpressionsForTask(taskId).Any(); + /// /// This validator has the code "Expression" and this is known by the frontend, who may request this validator to not run for incremental validation. /// @@ -66,19 +73,22 @@ public async Task> Validate( string? language ) { - var dataTypes = (await _appMetadata.GetApplicationMetadata()).DataTypes; - var formDataElementsForTask = instance - .Data.Where(d => - { - var dataType = dataTypes.Find(dt => dt.Id == d.DataType); - return dataType != null && dataType.TaskId == taskId; - }) - .ToList(); var validationIssues = new List(); - foreach (var dataElement in formDataElementsForTask) + foreach (var (dataType, validationConfig) in GetDataTypesWithExpressionsForTask(taskId)) { - var issues = await ValidateFormData(instance, dataElement, instanceDataAccessor, taskId, language); - validationIssues.AddRange(issues); + var formDataElementsForTask = instance.Data.Where(d => d.DataType == dataType.Id); + foreach (var dataElement in formDataElementsForTask) + { + var issues = await ValidateFormData( + instance, + dataElement, + instanceDataAccessor, + validationConfig, + taskId, + language + ); + validationIssues.AddRange(issues); + } } return validationIssues; @@ -88,17 +98,11 @@ internal async Task> ValidateFormData( Instance instance, DataElement dataElement, IInstanceDataAccessor dataAccessor, + string rawValidationConfig, string taskId, string? language ) { - var rawValidationConfig = _appResourceService.GetValidationConfiguration(dataElement.DataType); - if (rawValidationConfig == null) - { - // No validation configuration exists for this data type - return new List(); - } - using var validationConfig = JsonDocument.Parse(rawValidationConfig); var evaluatorState = await _layoutEvaluatorStateInitializer.Init( @@ -425,4 +429,19 @@ out JsonElement validationsObject } return expressionValidations; } + + private IEnumerable> GetDataTypesWithExpressionsForTask(string taskId) + { + var appMetadata = _appMetadata.GetApplicationMetadata().Result; + foreach ( + var dataType in appMetadata.DataTypes.Where(dt => dt.TaskId == taskId && dt.AppLogic?.ClassRef is not null) + ) + { + var validationConfig = _appResourceService.GetValidationConfiguration(dataType.Id); + if (validationConfig != null) + { + yield return KeyValuePair.Create(dataType, validationConfig); + } + } + } } diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs index 31223689f..39a4a288c 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -10,20 +11,31 @@ namespace Altinn.App.Core.Features.Validation.Default; public class RequiredLayoutValidator : IValidator { private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + private readonly IAppResources _appResources; /// /// Initializes a new instance of the class. /// - public RequiredLayoutValidator(ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) + public RequiredLayoutValidator( + ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, + IAppResources appResources + ) { _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + _appResources = appResources; } /// - /// Run for all tasks + /// We implement instead /// public string TaskId => "*"; + /// + /// Only run for tasks that specifies a layout set + /// + public bool ShouldRunForTask(string taskId) => + _appResources.GetLayoutSet()?.Sets.SelectMany(s => s.Tasks ?? []).Any(t => t == taskId) ?? false; + /// /// This validator has the code "Required" and this is known by the frontend, who may request this validator to not run for incremental validation. /// diff --git a/src/Altinn.App.Core/Helpers/MemoryAsStream.cs b/src/Altinn.App.Core/Helpers/MemoryAsStream.cs index 01a266071..03f2f4bdd 100644 --- a/src/Altinn.App.Core/Helpers/MemoryAsStream.cs +++ b/src/Altinn.App.Core/Helpers/MemoryAsStream.cs @@ -1,5 +1,8 @@ namespace Altinn.App.Core.Helpers; +/// +/// A read only stream that can be used to pass to a function that request a Stream without any copying. +/// internal class MemoryAsStream : Stream { private readonly ReadOnlyMemory _memory; diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs index a6408f665..e0320d70b 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs @@ -88,12 +88,7 @@ public ReadOnlyMemory SerializeToXml(object model) XmlSerializer serializer = _xmlSerializer.GetSerializer(modelType); serializer.Serialize(xmlWriter, model); - if (!memoryStream.TryGetBuffer(out var segment)) - { - throw new InvalidOperationException("Failed to get buffer from memory stream"); - } - - return segment.AsMemory().RemoveBom(); + return memoryStream.ToArray().AsMemory().RemoveBom(); } /// @@ -161,11 +156,8 @@ public object DeserializeJson(ReadOnlySpan data, Type modelType) } /// - /// + /// Deserialize utf8 encoded xml data to a model of the specified type /// - /// - /// - /// public object DeserializeXml(ReadOnlySpan data, Type modelType) { using var activity = _telemetry?.StartDeserializeFromXmlActivity(modelType); diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 2ebf29295..3d768f894 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -317,17 +317,23 @@ public LayoutModel GetLayoutModel(string? layoutSetId = null) } /// - public LayoutModel GetLayoutModelForTask(string taskId) + public LayoutModel? GetLayoutModelForTask(string taskId) { using var activity = _telemetry?.StartGetLayoutModelActivity(); - var layoutSets = GetLayoutSet() ?? throw new InvalidOperationException("no layout sets found"); + var layoutSets = GetLayoutSet(); + if (layoutSets is null) + { + return null; + } var dataTypes = _appMetadata.GetApplicationMetadata().Result.DataTypes; var layouts = layoutSets.Sets.Select(set => LoadLayout(set, dataTypes)).ToList(); - var layoutSet = - GetLayoutSetForTask(taskId) - ?? throw new InvalidOperationException("No layout set found for task " + taskId); + var layoutSet = GetLayoutSetForTask(taskId); + if (layoutSet is null) + { + return null; + } return new LayoutModel(layouts, layoutSet); } diff --git a/src/Altinn.App.Core/Internal/App/IAppResources.cs b/src/Altinn.App.Core/Internal/App/IAppResources.cs index dc50f69d6..747e43221 100644 --- a/src/Altinn.App.Core/Internal/App/IAppResources.cs +++ b/src/Altinn.App.Core/Internal/App/IAppResources.cs @@ -128,7 +128,7 @@ public interface IAppResources /// /// Gets the full layout model for the task /// - LayoutModel GetLayoutModelForTask(string taskId); + LayoutModel? GetLayoutModelForTask(string taskId); /// /// Gets the full layout model for the optional set diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index fc93e6949..b10dc133b 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -14,22 +14,35 @@ namespace Altinn.App.Core.Internal.Data; /// /// Do not add this to the DI container, as it should only be created explicitly because of data leak potential. /// -internal sealed class CachedInstanceDataAccessor : IInstanceDataAccessor +internal sealed class CachedInstanceDataAccessor : IInstanceDataMutator { + // DataClient needs a few arguments to fetch data private readonly string _org; private readonly string _app; private readonly Guid _instanceGuid; private readonly int _instanceOwnerPartyId; + + // Services from DI private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; private readonly ModelSerializationService _modelSerializationService; + + // Caches + // Cache for the most up to date form data (can be mutated or replaced with SetFormData(dataElementId, data)) private readonly LazyCache _formDataCache = new(); + + // Cache for the binary content of the file as currently in storage (updated on save) private readonly LazyCache> _binaryCache = new(); + + // Data elements to delete (eg RemoveDataElement(dataElementId)), but not yet deleted from instance or storage private readonly ConcurrentBag _dataElementsToDelete = new(); + + // Data elements to add (eg AddFormDataElement(dataTypeString, data)), but not yet added to instance or storage private readonly ConcurrentBag<( DataType dataType, string contentType, string? filename, + object? model, ReadOnlyMemory bytes )> _dataElementsToAdd = new(); @@ -68,6 +81,7 @@ public async Task GetFormData(DataElementId dataElementId) ); } + /// public async Task> GetBinaryData(DataElementId dataElementId) => await _binaryCache.GetOrCreate( dataElementId, @@ -82,7 +96,7 @@ public DataElement GetDataElement(DataElementId dataElementId) ?? throw new InvalidOperationException($"Data element with id {dataElementId.Id} not found in instance"); } - public DataType GetDataType(DataElementId dataElementId) + private DataType GetDataType(DataElementId dataElementId) { var dataElement = GetDataElement(dataElementId); var appMetadata = _appMetadata.GetApplicationMetadata().Result; @@ -96,7 +110,7 @@ public DataType GetDataType(DataElementId dataElementId) } /// - public void AddFormDataElement(string dataTypeString, object data) + public void AddFormDataElement(string dataTypeString, object model) { var dataType = GetDataTypeByString(dataTypeString); if (dataType.AppLogic?.ClassRef is not { } classRef) @@ -106,7 +120,7 @@ public void AddFormDataElement(string dataTypeString, object data) ); } - var modelType = data.GetType(); + var modelType = model.GetType(); if (modelType.FullName != classRef) { throw new InvalidOperationException( @@ -114,9 +128,10 @@ public void AddFormDataElement(string dataTypeString, object data) ); } - var (bytes, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); + ObjectUtils.InitializeAltinnRowId(model); + var (bytes, contentType) = _modelSerializationService.SerializeToStorage(model, dataType); - _dataElementsToAdd.Add((dataType, contentType, null, bytes)); + _dataElementsToAdd.Add((dataType, contentType, null, model, bytes)); } /// @@ -134,23 +149,22 @@ ReadOnlyMemory bytes $"Data type {dataTypeString} has a AppLogic.ClassRef in app metadata, and is not a binary data element" ); } - _dataElementsToAdd.Add((dataType, contentType, filename, bytes)); + _dataElementsToAdd.Add((dataType, contentType, filename, null, bytes)); } /// public void RemoveDataElement(DataElementId dataElementId) { - var idAsString = dataElementId.ToString(); - var dataElement = Instance.Data.Find(d => d.Id == idAsString); + var dataElement = Instance.Data.Find(d => d.Id == dataElementId.Id); if (dataElement is null) { - throw new InvalidOperationException($"Data element with id {idAsString} not found in instance"); + throw new InvalidOperationException($"Data element with id {dataElementId.Id} not found in instance"); } _dataElementsToDelete.Add(dataElementId); } - public List GetDataElementChanges() + public List GetDataElementChanges(bool initializeAltinnRowId) { var changes = new List(); foreach (var dataElement in Instance.Data) @@ -162,11 +176,12 @@ public List GetDataElementChanges() continue; var dataType = GetDataType(dataElementId); var previousBinary = _binaryCache.GetCachedValueOrDefault(dataElementId); - - ObjectUtils.InitializeAltinnRowId(data); - ObjectUtils.PrepareModelForXmlStorage(data); - - var (currentBinary, _) = _modelSerializationService.SerializeToStorage(data, dataType); + if (initializeAltinnRowId) + { + ObjectUtils.InitializeAltinnRowId(data); + } + var (currentBinary, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); + _binaryCache.Set(dataElementId, currentBinary); if (!currentBinary.Span.SequenceEqual(previousBinary.Span)) { @@ -178,7 +193,9 @@ public List GetDataElementChanges() PreviousFormData = _modelSerializationService.DeserializeFromStorage( previousBinary.Span, dataType - ) + ), + CurrentBinaryData = currentBinary, + PreviousBinaryData = previousBinary, } ); } @@ -195,7 +212,7 @@ internal async Task UpdateInstanceData() // Updating and deleting is done in SaveChanges and happen in parallel with validation. // Upload added data elements - foreach (var (dataType, contentType, filename, bytes) in _dataElementsToAdd) + foreach (var (dataType, contentType, filename, data, bytes) in _dataElementsToAdd) { async Task InsertBinaryData() { @@ -206,6 +223,11 @@ async Task InsertBinaryData() filename, new MemoryAsStream(bytes) ); + _binaryCache.Set(dataElement, bytes); + if (data is not null) + { + _formDataCache.Set(dataElement, data); + } createdDataElements.Add(dataElement); } @@ -239,31 +261,26 @@ await _dataClient.DeleteData( Instance.Data.AddRange(createdDataElements); } - internal async Task SaveChanges(List changes, bool initializeRowId) + internal async Task SaveChanges(List changes) { var tasks = new List(); foreach (var change in changes) { - var dataType = GetDataType(change.DataElement); - if (initializeRowId) + if (change.CurrentBinaryData is null) { - ObjectUtils.InitializeAltinnRowId(change.CurrentFormData); + throw new InvalidOperationException("Changes sent to SaveChanges must have a CurrentBinaryData value"); } - var (binaryData, contentType) = _modelSerializationService.SerializeToStorage( - change.CurrentFormData, - dataType - ); - // Update cache so that we can compare with the saved data to ensure no changes after save - _binaryCache.Set(change.DataElement, binaryData); + var dataElement = GetDataElement(change.DataElement); + tasks.Add( _dataClient.UpdateBinaryData( new InstanceIdentifier(Instance), - contentType, - null, - change.DataElement.Guid, - new MemoryAsStream(binaryData) + dataElement.ContentType, + dataElement.Filename, + Guid.Parse(change.DataElement.Id), + new MemoryAsStream(change.CurrentBinaryData.Value) ) ); } @@ -276,9 +293,31 @@ internal async Task SaveChanges(List changes, bool initialize /// internal void SetFormData(DataElementId dataElementId, object data) { + var dataType = GetDataType(dataElementId); + if (dataType.AppLogic?.ClassRef is not { } classRef) + { + throw new InvalidOperationException($"Data element {dataElementId.Id} don't have app logic"); + } + if (data.GetType().FullName != classRef) + { + throw new InvalidOperationException( + $"Data object registered for {dataElementId.Id} is not of type {classRef} as specified in application metadata for data type {dataType.Id}, but {data.GetType().FullName}" + ); + } _formDataCache.Set(dataElementId, data); } + /// + /// Compatibility function to update both formDataCache and binaryCache as we assume storage has already been updated. + /// + [Obsolete("Should only be used for actions that set UpdatedDataModels on UserActionResult which is deprecated")] + internal void ReplaceFormDataAssumeSavedToStorage(DataElementId dataElementId, object newModel) + { + SetFormData(dataElementId, newModel); + var (data, _) = _modelSerializationService.SerializeToStorage(newModel, GetDataType(dataElementId)); + _binaryCache.Set(dataElementId, data); + } + /// /// Simple wrapper around a Dictionary using Lazy to ensure that the valueFactory is only called once /// @@ -339,7 +378,7 @@ private DataType GetDataTypeByString(string dataTypeString) internal void VerifyDataElementsUnchanged() { - var changes = GetDataElementChanges(); + var changes = GetDataElementChanges(initializeAltinnRowId: false); if (changes.Count > 0) { throw new InvalidOperationException( diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 8f58e8217..37d4e0fe6 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -82,7 +82,7 @@ public DataElement GetDataElement(DataElementId dataElementId) } // Not implemented - public void AddFormDataElement(string dataType, object data) + public void AddFormDataElement(string dataType, object model) { throw new NotImplementedException( "The obsolete LayoutEvaluatorStateInitializer.Init method does not support adding data elements" diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index a63ef2c64..8442bd914 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -1,4 +1,5 @@ using Altinn.App.Core.Features; +using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -17,7 +18,7 @@ public class DataPatchResult /// /// The validation issues that were found during the patch operation. /// - public required Dictionary> ValidationIssues { get; init; } + public required List ValidationIssues { get; init; } /// /// The current data model after the patch operation. @@ -27,5 +28,12 @@ public class DataPatchResult /// /// Get updated data elements that have app logic in a dictionary with the data element id as key. /// - public required Dictionary UpdatedData { get; init; } + public required List UpdatedData { get; init; } + + /// + /// Store a pair with Id and Data + /// + /// The data element id + /// The deserialized data + public record DataModelPair(DataElementId Id, object Data); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index efc9e4c14..c7fafe014 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -10,6 +10,8 @@ using Altinn.App.Core.Models.Result; using Altinn.Platform.Storage.Interface.Models; using Json.Patch; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; namespace Altinn.App.Core.Internal.Patch; @@ -21,9 +23,11 @@ internal class PatchService : IPatchService private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; private readonly ModelSerializationService _modelSerializationService; + private readonly IWebHostEnvironment _hostingEnvironment; private readonly Telemetry? _telemetry; private readonly IValidationService _validationService; private readonly IEnumerable _dataProcessors; + private readonly IEnumerable _dataWriteProcessors; private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, PropertyNameCaseInsensitive = true, }; @@ -36,7 +40,9 @@ public PatchService( IDataClient dataClient, IValidationService validationService, IEnumerable dataProcessors, + IEnumerable dataWriteProcessors, ModelSerializationService modelSerializationService, + IWebHostEnvironment hostingEnvironment, Telemetry? telemetry = null ) { @@ -44,7 +50,9 @@ public PatchService( _dataClient = dataClient; _validationService = validationService; _dataProcessors = dataProcessors; + _dataWriteProcessors = dataWriteProcessors; _modelSerializationService = modelSerializationService; + _hostingEnvironment = hostingEnvironment; _telemetry = telemetry; } @@ -58,9 +66,6 @@ public async Task> ApplyPatches( { using var activity = _telemetry?.StartDataPatchActivity(instance); - InstanceIdentifier instanceIdentifier = new(instance); - AppIdentifier appIdentifier = (await _appMetadata.GetApplicationMetadata()).AppIdentifier; - var dataAccessor = new CachedInstanceDataAccessor( instance, _dataClient, @@ -82,6 +87,7 @@ public async Task> ApplyPatches( Detail = $"Data element with id {dataElementGuid} not found in instance", }; } + DataElementId dataElementId = dataElement; var oldModel = await dataAccessor.GetFormData(dataElementId); // TODO: Fetch data in parallel @@ -116,6 +122,7 @@ public async Task> ApplyPatches( ErrorType = DataPatchErrorType.DeserializationFailed }; } + var newModel = newModelResult.Ok; // Reset dataAccessor to provide the patched model. dataAccessor.SetFormData(dataElement, newModel); @@ -123,9 +130,11 @@ public async Task> ApplyPatches( changesAfterPatch.Add( new DataElementChange { - DataElement = dataElementId, + DataElement = dataElement, PreviousFormData = oldModel, - CurrentFormData = newModel + CurrentFormData = newModel, + PreviousBinaryData = await dataAccessor.GetBinaryData(dataElementId), + CurrentBinaryData = null, } ); } @@ -134,13 +143,14 @@ public async Task> ApplyPatches( { foreach (var change in changesAfterPatch) { + var dataElementGuid = Guid.Parse(change.DataElement.Id); using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataProcessor); try { // TODO: Create new dataProcessor interface that takes multiple models at the same time. await dataProcessor.ProcessDataWrite( instance, - change.DataElement.Guid, + dataElementGuid, change.CurrentFormData, change.PreviousFormData, language @@ -152,20 +162,31 @@ await dataProcessor.ProcessDataWrite( throw; } } + } - // TODO: add new method to IDataProcessor that takes multiple models at the same time - await dataProcessor.ProcessDataWrite( - dataAccessor, - instance.Process.CurrentTask.ElementId, - changesAfterPatch, - language - ); + foreach (var dataWriteProcessor in _dataWriteProcessors) + { + using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataWriteProcessor); + try + { + await dataWriteProcessor.ProcessDataWrite( + dataAccessor, + instance.Process.CurrentTask.ElementId, + changesAfterPatch, + language + ); + } + catch (Exception e) + { + processWriteActivity?.Errored(e); + throw; + } } // Get all changes to data elements by comparing the serialized values - var changes = dataAccessor.GetDataElementChanges(); + var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: true); // Start saving changes in parallel with validation - Task saveChanges = dataAccessor.SaveChanges(changes, initializeRowId: true); + Task saveChanges = dataAccessor.SaveChanges(changes); // Update instance data to reflect the changes and save created data elements await dataAccessor.UpdateInstanceData(); @@ -181,26 +202,28 @@ await dataProcessor.ProcessDataWrite( // don't await saving until validation is done, so that they run in parallel await saveChanges; - if (true) // TODO: only run in development mode + if (_hostingEnvironment.IsDevelopment()) { // Ensure that validation did not change the data elements dataAccessor.VerifyDataElementsUnchanged(); } - var updatedData = changes.ToDictionary( - d => d.DataElement.Guid, - d => - d.CurrentFormData - ?? throw new InvalidOperationException("Data element has app logic but no current value") - ); + var updatedData = changes + .Select(change => new DataPatchResult.DataModelPair(change.DataElement, change.CurrentFormData)) + .ToList(); // Ensure that all data elements that were patched are included in the updated data // (even if they were not changed or the change was reverted by dataProcessor) - foreach (var patchedElementGuid in patches.Keys.Where(g => !updatedData.ContainsKey(g))) + foreach (var patchedElementGuid in patches.Keys) { - var dataElement = - instance.Data.Find(d => d.Id == patchedElementGuid.ToString()) - ?? throw new InvalidOperationException("Data element not found in instance"); - updatedData.Add(patchedElementGuid, dataAccessor.GetFormData(dataElement)); + if (changes.TrueForAll(c => c.DataElement.Id != patchedElementGuid.ToString())) + { + var dataElement = + instance.Data.Find(d => d.Id == patchedElementGuid.ToString()) + ?? throw new InvalidOperationException("Data element not found in instance"); + updatedData.Add( + new DataPatchResult.DataModelPair(dataElement, await dataAccessor.GetFormData(dataElement)) + ); + } } return new DataPatchResult diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 44f41a2d6..588de6a7a 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -4,6 +4,9 @@ using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; using Altinn.App.Core.Internal.Process.ProcessTasks; @@ -29,18 +32,13 @@ public class ProcessEngine : IProcessEngine private readonly UserActionService _userActionService; private readonly Telemetry? _telemetry; private readonly IProcessTaskCleaner _processTaskCleaner; + private readonly IDataClient _dataClient; + private readonly ModelSerializationService _modelSerialization; + private readonly IAppMetadata _appMetadata; /// /// Initializes a new instance of the class /// - /// Process reader service - /// The profile service - /// The process navigator - /// The process events delegator - /// The process event dispatcher - /// The process task cleaner - /// The action handler factory - /// The telemetry service public ProcessEngine( IProcessReader processReader, IProfileClient profileClient, @@ -49,6 +47,9 @@ public ProcessEngine( IProcessEventDispatcher processEventDispatcher, IProcessTaskCleaner processTaskCleaner, UserActionService userActionService, + IDataClient dataClient, + ModelSerializationService modelSerialization, + IAppMetadata appMetadata, Telemetry? telemetry = null ) { @@ -59,6 +60,9 @@ public ProcessEngine( _processEventDispatcher = processEventDispatcher; _processTaskCleaner = processTaskCleaner; _userActionService = userActionService; + _dataClient = dataClient; + _modelSerialization = modelSerialization; + _appMetadata = appMetadata; _telemetry = telemetry; } @@ -163,10 +167,16 @@ public async Task Next(ProcessNextRequest request) int? userId = request.User.GetUserIdAsInt(); IUserAction? actionHandler = _userActionService.GetActionHandler(request.Action); + var cachedDataMutator = new CachedInstanceDataAccessor( + instance, + _dataClient, + _appMetadata, + _modelSerialization + ); UserActionResult actionResult = actionHandler is null ? UserActionResult.SuccessResult() - : await actionHandler.HandleAction(new UserActionContext(request.Instance, userId)); + : await actionHandler.HandleAction(new UserActionContext(cachedDataMutator, userId)); if (actionResult.ResultType != ResultType.Success) { @@ -180,6 +190,10 @@ public async Task Next(ProcessNextRequest request) return result; } + var changes = cachedDataMutator.GetDataElementChanges(initializeAltinnRowId: false); + await cachedDataMutator.UpdateInstanceData(); + await cachedDataMutator.SaveChanges(changes); + ProcessStateChange? nextResult = await HandleMoveToNext(instance, request.User, request.Action); if (nextResult?.NewProcessState?.Ended is not null) diff --git a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs index 1f67449a8..cf4db5d18 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidationService.cs @@ -39,7 +39,7 @@ Task> ValidateInstanceAtTask( /// List of to ignore /// The language to run validations in /// Dictionary where the key is the and the value is the list of issues this validator produces - public Task>> ValidateIncrementalFormData( + public Task> ValidateIncrementalFormData( Instance instance, IInstanceDataAccessor dataAccessor, string taskId, diff --git a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs index fda064a36..9498441e4 100644 --- a/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs +++ b/src/Altinn.App.Core/Internal/Validation/IValidatorFactory.cs @@ -58,6 +58,11 @@ IAppMetadata appMetadata _appMetadata = appMetadata; } + private IEnumerable GetIValidators(string taskId) + { + return _validators.Where(v => v.ShouldRunForTask(taskId)); + } + private IEnumerable GetTaskValidators(string taskId) { return _taskValidators.Where(tv => tv.TaskId == "*" || tv.TaskId == taskId); @@ -121,7 +126,7 @@ public IEnumerable GetValidators(string taskId) { var validators = new List(); // add new style validators - validators.AddRange(_validators); + validators.AddRange(GetIValidators(taskId)); // add legacy task validators, data element validators and form data validators validators.AddRange(GetTaskValidators(taskId).Select(tv => new TaskValidatorWrapper(tv))); var dataTypes = _appMetadata.GetApplicationMetadata().Result.DataTypes; diff --git a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs index 5ed91d0ae..f2c4aa3b2 100644 --- a/src/Altinn.App.Core/Internal/Validation/ValidationService.cs +++ b/src/Altinn.App.Core/Internal/Validation/ValidationService.cs @@ -93,7 +93,7 @@ public async Task> ValidateInstanceAtTask( } /// - public async Task>> ValidateIncrementalFormData( + public async Task> ValidateIncrementalFormData( Instance instance, IInstanceDataAccessor dataAccessor, string taskId, @@ -136,13 +136,10 @@ public async Task>> ValidateI ) ) .ToList(); - return new KeyValuePair?>( - validator.ValidationSource, - issuesWithSource - ); + return new ValidationSourcePair(validator.ValidationSource, issuesWithSource); } - return new KeyValuePair?>(); + return null; } catch (Exception e) { @@ -159,12 +156,12 @@ public async Task>> ValidateI }); // Wait for all validation tasks to complete + var lists = await Task.WhenAll(validationTasks); - var errorCount = lists.Sum(k => k.Value?.Count ?? 0); - activity?.SetTag(Telemetry.InternalLabels.ValidationTotalIssueCount, errorCount); - // ! Value is null if no relevant changes. Filter out these before return with ! because ofType don't filter nullables. - return lists.Where(k => k.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value!); + var errorCount = lists.Sum(k => k?.Issues.Count ?? 0); + activity?.SetTag(Telemetry.InternalLabels.ValidationTotalIssueCount, errorCount); + return lists.OfType().ToList(); } private static void ThrowIfDuplicateValidators(IValidator[] validators, string taskId) diff --git a/src/Altinn.App.Core/Models/DataElementId.cs b/src/Altinn.App.Core/Models/DataElementId.cs index 9b562a4c2..e335d7c28 100644 --- a/src/Altinn.App.Core/Models/DataElementId.cs +++ b/src/Altinn.App.Core/Models/DataElementId.cs @@ -5,32 +5,39 @@ namespace Altinn.App.Core.Models; /// /// Wrapper type for a /// -/// The guid as a Guid -/// The guid ID as string -/// The data type id from app metadata -public readonly record struct DataElementId(Guid Guid, string Id, string DataType) +public readonly struct DataElementId : IEquatable { /// - /// Override equality to only compare the guid + /// The backing field for the parsed guid that identifies a /// - public bool Equals(DataElementId other) - { - return Guid.Equals(other.Guid); - } + public Guid Guid { get; } /// - /// Override equality to only compare the guid + /// The backing field for the string containing a guid that identifies a /// - public override int GetHashCode() + public string Id { get; } + + private DataElementId(Guid guid, string id) { - return Guid.GetHashCode(); + Guid = guid; + Id = id; } /// /// Implicit conversion to allow DataElements to be used as DataElementIds /// public static implicit operator DataElementId(DataElement dataElement) => - new(Guid.Parse(dataElement.Id), dataElement.Id, dataElement.DataType); + new(Guid.Parse(dataElement.Id), dataElement.Id); + + /// + /// Make the implicit conversion from string (containing a valid guid) to DataElementIdentifier work + /// + public static explicit operator DataElementId(string id) => new(Guid.Parse(id), id); + + /// + /// Make the implicit conversion from guid to DataElementIdentifier work + /// + public static explicit operator DataElementId(Guid guid) => new(guid, guid.ToString()); /// /// Make the ToString method return the ID @@ -39,4 +46,38 @@ public override string ToString() { return Id; } + + /// + public override bool Equals(object? obj) + { + return obj is DataElementId other && Equals(other); + } + + /// + public static bool operator ==(DataElementId left, DataElementId right) + { + return left.Equals(right); + } + + /// + public static bool operator !=(DataElementId left, DataElementId right) + { + return !left.Equals(right); + } + + /// + /// Override equality to only compare the guid + /// + public bool Equals(DataElementId other) + { + return Guid.Equals(other.Guid); + } + + /// + /// Override equality to only compare the guid + /// + public override int GetHashCode() + { + return Guid.GetHashCode(); + } } diff --git a/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs b/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs index f48370b29..602e4964c 100644 --- a/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs +++ b/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Models.UserAction; @@ -7,6 +8,30 @@ namespace Altinn.App.Core.Models.UserAction; /// public class UserActionContext { + /// + /// Creates a new instance of the class + /// + /// The instance the action is performed on + /// The user performing the action + /// The id of the button that triggered the action (optional) + /// + /// The currently used language by the user (or null if not available) + public UserActionContext( + IInstanceDataMutator dataMutator, + int? userId, + string? buttonId = null, + Dictionary? actionMetadata = null, + string? language = null + ) + { + Instance = dataMutator.Instance; + DataMutator = dataMutator; + UserId = userId; + ButtonId = buttonId; + ActionMetadata = actionMetadata ?? new Dictionary(); + Language = language; + } + /// /// Creates a new instance of the class /// @@ -15,6 +40,7 @@ public class UserActionContext /// The id of the button that triggered the action (optional) /// /// The currently used language by the user (or null if not available) + [Obsolete("Use the constructor with IInstanceDataAccessor instead")] public UserActionContext( Instance instance, int? userId, @@ -24,6 +50,8 @@ public UserActionContext( ) { Instance = instance; + // ! TODO: Deprecated constructor, remove in v9 + DataMutator = null!; UserId = userId; ButtonId = buttonId; ActionMetadata = actionMetadata ?? new Dictionary(); @@ -35,6 +63,11 @@ public UserActionContext( /// public Instance Instance { get; } + /// + /// Access dataElements through this accessor to ensure that changes gets saved in storage and returned to frontend + /// + public IInstanceDataAccessor DataMutator { get; } + /// /// The user performing the action /// diff --git a/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs b/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs index e143141f4..ae68eda31 100644 --- a/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs +++ b/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs @@ -26,7 +26,7 @@ public enum ResultType /// /// Represents the result of a user action /// -public class UserActionResult +public sealed class UserActionResult { /// /// Gets or sets a value indicating whether the user action was a success diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs index 466a7d8ac..fc050eeab 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueWithSource.cs @@ -99,3 +99,10 @@ public static ValidationIssueWithSource FromIssue(ValidationIssue issue, string [JsonPropertyName("customTextParams")] public List? CustomTextParams { get; set; } } + +/// +/// API responses that returns validation issues grouped by source, typically return a list of these. +/// +/// The for the Validator that created theese issues +/// List of issues +public record ValidationSourcePair(string Source, List Issues); diff --git a/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs index 5dd838be4..828af761d 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs @@ -17,8 +17,13 @@ namespace Altinn.App.Api.Tests.Controllers; public class ActionsControllerTests : ApiTestBase, IClassFixture> { + private readonly ITestOutputHelper _outputHelper; + public ActionsControllerTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) - : base(factory, outputHelper) { } + : base(factory, outputHelper) + { + _outputHelper = outputHelper; + } [Fact] public async Task Perform_returns_403_if_user_not_authorized() @@ -189,7 +194,8 @@ public async Task Perform_returns_200_if_action_succeeded() var content = await response.Content.ReadAsStringAsync(); var expectedString = """ { - "updatedDataModels": null, + "updatedDataModels": {}, + "updatedValidationIssues": {}, "clientActions": [ { "id": "nextPage", @@ -333,8 +339,10 @@ public async Task Perform_returns_404_if_action_implementation_not_found() } //TODO: replace this assertion with a proper one once fluentassertions has a json compare feature scheduled for v7 https://github.com/fluentassertions/fluentassertions/issues/2205 - private static void CompareResult(string expectedString, string actualString) + private void CompareResult(string expectedString, string actualString) { + _outputHelper.WriteLine($"Expected: {expectedString}"); + _outputHelper.WriteLine($"Actual: {actualString}"); T? expected = JsonSerializer.Deserialize(expectedString); T? actual = JsonSerializer.Deserialize(actualString); actual.Should().BeEquivalentTo(expected); diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 54cac5281..306f6566e 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -29,7 +29,7 @@ namespace Altinn.App.Api.Tests.Controllers; public class DataControllerPatchTests : ApiTestBase, IClassFixture> { - protected static new readonly JsonSerializerOptions JsonSerializerOptions = + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -93,7 +93,7 @@ TResponse parsedResponse httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var serializedPatch = JsonSerializer.Serialize( new DataPatchRequest() { Patch = patch, IgnoredValidators = ignoredValidators, }, - JsonSerializerOptions + _jsonSerializerOptions ); OutputHelper.WriteLine(serializedPatch); using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); @@ -101,9 +101,9 @@ TResponse parsedResponse var responseString = await response.Content.ReadAsStringAsync(); using var responseParsedRaw = JsonDocument.Parse(responseString); OutputHelper.WriteLine("\nResponse:"); - OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, JsonSerializerOptions)); + OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, _jsonSerializerOptions)); response.Should().HaveStatusCode(expectedStatus); - var responseObject = JsonSerializer.Deserialize(responseString, JsonSerializerOptions)!; + var responseObject = JsonSerializer.Deserialize(responseString, _jsonSerializerOptions)!; return (response, responseString, responseObject); } @@ -124,16 +124,16 @@ TResponse parsedResponse url += $"?language={language}"; } OutputHelper.WriteLine($"Calling PATCH {url}"); - var serializedPatch = JsonSerializer.Serialize(requestMultiple, JsonSerializerOptions); + var serializedPatch = JsonSerializer.Serialize(requestMultiple, _jsonSerializerOptions); OutputHelper.WriteLine(serializedPatch); using var updateDataElementContent = new StringContent(serializedPatch, Encoding.UTF8, "application/json"); var response = await GetClient().PatchAsync(url, updateDataElementContent); var responseString = await response.Content.ReadAsStringAsync(); using var responseParsedRaw = JsonDocument.Parse(responseString); OutputHelper.WriteLine("\nResponse:"); - OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, JsonSerializerOptions)); + OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, _jsonSerializerOptions)); response.Should().HaveStatusCode(expectedStatus); - var responseObject = JsonSerializer.Deserialize(responseString, JsonSerializerOptions)!; + var responseObject = JsonSerializer.Deserialize(responseString, _jsonSerializerOptions)!; return (response, responseString, responseObject); } @@ -229,7 +229,7 @@ public async Task MultiplePatches_AppliesCorrectly() OutputHelper.WriteLine(createExtraElementResponseString); createExtraElementResponse.Should().HaveStatusCode(HttpStatusCode.Created); var extraDataId = JsonSerializer - .Deserialize(createExtraElementResponseString, JsonSerializerOptions) + .Deserialize(createExtraElementResponseString, _jsonSerializerOptions) ?.Id; extraDataId.Should().NotBeNull(); var extraDataGuid = Guid.Parse(extraDataId!); @@ -249,19 +249,25 @@ public async Task MultiplePatches_AppliesCorrectly() var (_, _, parsedResponse) = await CallPatchMultipleApi(request, HttpStatusCode.OK); - parsedResponse.ValidationIssues.Should().ContainKey("Required").WhoseValue.Should().BeEmpty(); + parsedResponse + .ValidationIssues.Should() + .ContainSingle(p => p.Source == "Required") + .Which.Issues.Should() + .BeEmpty(); - parsedResponse.NewDataModels.Should().HaveCount(2).And.ContainKey(_dataGuid).And.ContainKey(extraDataGuid); + parsedResponse.NewDataModels.Should().HaveCount(2); var newData = parsedResponse - .NewDataModels[_dataGuid] - .Should() + .NewDataModels.Should() + .ContainSingle(d => d.Id == _dataGuid) + .Which.Data.Should() .BeOfType() .Which.Deserialize()!; newData.Melding!.Name.Should().Be("Ola Olsen"); var newExtraData = parsedResponse - .NewDataModels[extraDataGuid] - .Should() + .NewDataModels.Should() + .ContainSingle(d => d.Id == extraDataGuid) + .Which.Data.Should() .BeOfType() .Which.Deserialize()!; newExtraData.Melding!.Name.Should().Be("Kari Olsen"); @@ -312,7 +318,7 @@ public async Task NullName_ReturnsOkAndValidationError() var validationResponseString = await validationResponse.Content.ReadAsStringAsync(); var validationResponseObject = JsonSerializer.Deserialize>( validationResponseString, - JsonSerializerOptions + _jsonSerializerOptions )!; validationResponseObject.Should().BeEquivalentTo(parsedResponse.ValidationIssues.Values.SelectMany(d => d)); @@ -852,9 +858,9 @@ public async Task DataReadChanges_IsPreservedWhenCallingPatch() var responseString = await response.Content.ReadAsStringAsync(); using var responseParsedRaw = JsonDocument.Parse(responseString); OutputHelper.WriteLine("\nResponse:"); - OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, JsonSerializerOptions)); + OutputHelper.WriteLine(JsonSerializer.Serialize(responseParsedRaw, _jsonSerializerOptions)); response.Should().HaveStatusCode(HttpStatusCode.OK); - var responseObject = JsonSerializer.Deserialize(responseString, JsonSerializerOptions)!; + var responseObject = JsonSerializer.Deserialize(responseString, _jsonSerializerOptions)!; responseObject.Melding!.Random.Should().Be("randomFromDataRead"); diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index f07c48f80..59102fb39 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -12,6 +12,14 @@ ActivityName: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSet, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSets, + IdFormat: W3C + }, { ActivityName: ApplicationMetadata.Service.GetLayoutSets, IdFormat: W3C @@ -32,6 +40,18 @@ ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, { ActivityName: Authorization.Service.AuthorizeAction, Tags: [ @@ -133,21 +153,6 @@ ], IdFormat: W3C }, - { - ActivityName: Validation.RunValidator, - Tags: [ - { - validation.issue_count: 0 - }, - { - validator.source: Expression - }, - { - validator.type: ExpressionValidator - } - ], - IdFormat: W3C - }, { ActivityName: Validation.RunValidator, Tags: [ diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index 12b98a8fd..bcad7a72b 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -12,6 +12,14 @@ ActivityName: ApplicationMetadata.Service.GetLayoutSet, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSet, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetLayoutSets, + IdFormat: W3C + }, { ActivityName: ApplicationMetadata.Service.GetLayoutSets, IdFormat: W3C @@ -32,6 +40,18 @@ ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, IdFormat: W3C }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, + { + ActivityName: ApplicationMetadata.Service.GetValidationConfiguration, + IdFormat: W3C + }, { ActivityName: Authorization.Service.AuthorizeAction, Tags: [ @@ -237,21 +257,6 @@ ], IdFormat: W3C }, - { - ActivityName: Validation.RunValidator, - Tags: [ - { - validation.issue_count: 0 - }, - { - validator.source: Expression - }, - { - validator.type: ExpressionValidator - } - ], - IdFormat: W3C - }, { ActivityName: Validation.RunValidator, Tags: [ diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index 4621dbccb..83e5c5a1c 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -56,7 +56,6 @@ public async Task DeleteData( bool delay ) { - await Task.CompletedTask; string dataElementPath = TestData.GetDataElementPath(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); if (delay) @@ -314,24 +313,13 @@ HttpRequest request Directory.CreateDirectory(dataPath + @"blob"); - long filesize; + using var stream = new MemoryStream(); + await request.Body.CopyToAsync(stream); - using ( - Stream streamToWriteTo = File.Open( - dataPath + @"blob/" + dataGuid, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.ReadWrite - ) - ) - { - await request.Body.CopyToAsync(streamToWriteTo); - streamToWriteTo.Flush(); - filesize = streamToWriteTo.Length; - streamToWriteTo.Close(); - } + var fileData = stream.ToArray(); + await File.WriteAllBytesAsync(dataPath + @"blob/" + dataGuid, fileData); - dataElement.Size = filesize; + dataElement.Size = fileData.Length; await WriteDataElementToFile(dataElement, org, app, instanceOwnerPartyId); diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 9363a8820..e32c41d70 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -1134,7 +1134,8 @@ } } } - } + }, + "deprecated": true }, "delete": { "tags": [ @@ -5659,6 +5660,19 @@ }, "additionalProperties": false }, + "DataModelPairResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "data": { + "nullable": true + } + }, + "additionalProperties": false + }, "DataPatchRequest": { "required": [ "ignoredValidators", @@ -5735,18 +5749,17 @@ "type": "object", "properties": { "validationIssues": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ValidationIssueWithSource" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationSourcePair" }, "nullable": true }, "newDataModels": { - "type": "object", - "additionalProperties": { }, + "type": "array", + "items": { + "$ref": "#/components/schemas/DataModelPairResponse" + }, "nullable": true }, "instance": { @@ -7063,6 +7076,23 @@ }, "additionalProperties": false }, + "ValidationSourcePair": { + "type": "object", + "properties": { + "source": { + "type": "string", + "nullable": true + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationIssueWithSource" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "ValidationStatus": { "type": "object", "properties": { diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index d7a88f22e..84d7a1c7c 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -690,6 +690,7 @@ paths: text/xml: schema: $ref: '#/components/schemas/ProblemDetails' + deprecated: true delete: tags: - Data @@ -3531,6 +3532,15 @@ components: type: string nullable: true additionalProperties: false + DataModelPairResponse: + type: object + properties: + id: + type: string + format: uuid + data: + nullable: true + additionalProperties: false DataPatchRequest: required: - ignoredValidators @@ -3586,15 +3596,14 @@ components: type: object properties: validationIssues: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/ValidationIssueWithSource' + type: array + items: + $ref: '#/components/schemas/ValidationSourcePair' nullable: true newDataModels: - type: object - additionalProperties: { } + type: array + items: + $ref: '#/components/schemas/DataModelPairResponse' nullable: true instance: $ref: '#/components/schemas/Instance' @@ -4547,6 +4556,18 @@ components: type: string nullable: true additionalProperties: false + ValidationSourcePair: + type: object + properties: + source: + type: string + nullable: true + issues: + type: array + items: + $ref: '#/components/schemas/ValidationIssueWithSource' + nullable: true + additionalProperties: false ValidationStatus: type: object properties: diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 0851ce5a8..d9568cffd 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -85,16 +85,17 @@ private async Task RunExpressionValidationTest(string fileName, string folder) init.Init(It.IsAny(), "Task_1", It.IsAny(), It.IsAny()) ) .ReturnsAsync(evaluatorState); - _appResources - .Setup(ar => ar.GetValidationConfiguration("default")) - .Returns(JsonSerializer.Serialize(testCase.ValidationConfig)); - _appResources - .Setup(ar => ar.GetLayoutSetForTask(null!)) - .Returns(new LayoutSet() { Id = "layout", DataType = "default", }); var dataAccessor = new InstanceDataAccessorFake(instance) { { dataElement, dataModel } }; - var validationIssues = await _validator.ValidateFormData(instance, dataElement, dataAccessor, "Task_1", null); + var validationIssues = await _validator.ValidateFormData( + instance, + dataElement, + dataAccessor, + JsonSerializer.Serialize(testCase.ValidationConfig), + "Task_1", + null + ); var result = validationIssues.Select(i => new { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index 21e1f236c..a6e9ac0fd 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -226,9 +226,14 @@ private void SetupFormDataValidatorReturn( .Verifiable(hasRelevantChanges is null ? Times.Never : Times.AtLeastOnce); } - private void SourcePropertyIsSet(Dictionary> result) + private void SourcePropertyIsSet(List result) { - Assert.All(result.Values, SourcePropertyIsSet); + var issues = result.SelectMany(p => p.Issues).ToArray(); + if (issues.Length == 0) + { + return; + } + issues.Should().AllSatisfy(i => i.Source.Should().NotBeNull()); } private void SourcePropertyIsSet(List result) @@ -326,7 +331,7 @@ public async Task ValidateFormData_WithSpecificValidator() DefaultLanguage ); - result.Should().ContainKey("specificValidator").WhoseValue.Should().HaveCount(0); + result.Should().ContainSingle(p => p.Source == "specificValidator").Which.Issues.Should().HaveCount(0); result.Should().HaveCount(1); SourcePropertyIsSet(result); } @@ -382,15 +387,15 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa ); resultData .Should() - .ContainKey("specificValidator") - .WhoseValue.Should() + .ContainSingle(p => p.Source == "specificValidator") + .Which.Issues.Should() .ContainSingle() .Which.CustomTextKey.Should() .Be("NameNotOla"); resultData .Should() - .ContainKey("alwaysUsedValidator") - .WhoseValue.Should() + .ContainSingle(p => p.Source == "alwaysUsedValidator") + .Which.Issues.Should() .ContainSingle() .Which.CustomTextKey.Should() .Be("AlwaysNameNotOla"); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt index 076347da0..b09bff7af 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental.verified.txt @@ -51,16 +51,19 @@ ], Metrics: [] }, - issues: { - Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default: [ - { - Severity: Error, - DataElementId: DataElementId_0, - Code: TestCode, - Description: Test error, - Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default, - NoIncrementalUpdates: false - } - ] - } + issues: [ + { + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default, + Issues: [ + { + Severity: Error, + DataElementId: DataElementId_0, + Code: TestCode, + Description: Test error, + Source: Altinn.App.Core.Tests.Features.Validators.ValidationServiceTests+GenericValidatorFake-default, + NoIncrementalUpdates: false + } + ] + } + ] } diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 770128e28..5a5fb1172 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -54,7 +54,7 @@ public Task InitializeAsync() return Task.CompletedTask; } - private Mock RegistrerValidatorMock( + private Mock RegisterValidatorMock( string source, bool? hasRelevantChanges = null, List? expectedChanges = null, @@ -64,6 +64,7 @@ private Mock RegistrerValidatorMock( ) { var mock = new Mock(MockBehavior.Strict); + mock.Setup(v => v.ShouldRunForTask(TaskId)).Returns(true); mock.Setup(v => v.ValidationSource).Returns(source); if (hasRelevantChanges.HasValue && expectedChanges is not null) { @@ -131,8 +132,8 @@ public async Task ValidateIncrementalFormData_WithIgnoredValidators_ShouldRunOnl var changes = new List(); var issues = new List(); - RegistrerValidatorMock("IgnoredValidator"); // Throws error if changes or validation is called - RegistrerValidatorMock("Validator", hasRelevantChanges: true, changes, issues); + RegisterValidatorMock("IgnoredValidator"); // Throws error if changes or validation is called + RegisterValidatorMock("Validator", hasRelevantChanges: true, changes, issues); var validationService = _serviceProvider.Value.GetRequiredService(); var result = await validationService.ValidateIncrementalFormData( @@ -143,7 +144,7 @@ public async Task ValidateIncrementalFormData_WithIgnoredValidators_ShouldRunOnl new List { "IgnoredValidator" }, null ); - result.Should().ContainKey("Validator").WhoseValue.Should().BeEmpty(); + result.Should().ContainSingle(p => p.Source == "Validator").Which.Issues.Should().BeEmpty(); } [Fact] @@ -157,8 +158,8 @@ public async Task ValidateInstanceAtTask_WithIgnoredValidators_ShouldRunOnlyNonI Code = "TestCode" }; - RegistrerValidatorMock(source: "IgnoredValidator"); - RegistrerValidatorMock( + RegisterValidatorMock(source: "IgnoredValidator"); + RegisterValidatorMock( source: "Validator", hasRelevantChanges: true, issues: [issue], @@ -189,13 +190,13 @@ public async Task ValidateInstanceAtTask_WithDifferentValidators_ShouldIgnoreNon bool? onlyIncrementalValidators ) { - var incrementalMock = RegistrerValidatorMock( + var incrementalMock = RegisterValidatorMock( source: "Validator", hasRelevantChanges: null, issues: [], noIncrementalValidation: false ); - var nonIncrementalMock = RegistrerValidatorMock( + var nonIncrementalMock = RegisterValidatorMock( source: "NonIncrementalValidator", hasRelevantChanges: null, issues: [], diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index e9de29226..988f9a962 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -14,6 +14,7 @@ using FluentAssertions; using Json.Patch; using Json.Pointer; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -46,6 +47,7 @@ public sealed class PatchServiceTests : IDisposable private readonly Mock _appMetadataMock = new(MockBehavior.Strict); private readonly Mock _httpContextAccessorMock = new(MockBehavior.Strict); private readonly TelemetrySink _telemetrySink = new(); + private readonly Mock _webHostEnvironment = new(MockBehavior.Strict); // ValidatorMocks private readonly Mock _formDataValidator = new(MockBehavior.Strict); @@ -83,6 +85,7 @@ public PatchServiceTests() ) .ReturnsAsync(_dataElement) .Verifiable(); + _webHostEnvironment.SetupGet(whe => whe.EnvironmentName).Returns("Development"); var validatorFactory = new ValidatorFactory( [], Options.Create(new GeneralSettings()), @@ -95,12 +98,15 @@ public PatchServiceTests() var validationService = new ValidationService(validatorFactory, _vLoggerMock.Object); _modelSerializationService = new ModelSerializationService(_appModelMock.Object); + _patchService = new PatchService( _appMetadataMock.Object, _dataClientMock.Object, validationService, - new List { _dataProcessorMock.Object }, + [_dataProcessorMock.Object], + [], _modelSerializationService, + _webHostEnvironment.Object, _telemetrySink.Object ); } @@ -173,8 +179,8 @@ public async Task Test_Ok() change.DataElement.Id.Should().Be(_dataGuid.ToString()); change.CurrentFormData.Should().BeOfType().Subject.Name.Should().Be("Test Testesen"); var validator = res.ValidationIssues.Should().ContainSingle().Which; - validator.Key.Should().Be("formDataValidator"); - var issue = validator.Value.Should().ContainSingle().Which; + validator.Source.Should().Be("formDataValidator"); + var issue = validator.Issues.Should().ContainSingle().Which; issue.Description.Should().Be("First error"); _dataProcessorMock.Verify(d => d.ProcessDataWrite(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.StartProcess_starts_process_and_moves_to_first_task.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.StartProcess_starts_process_and_moves_to_first_task.verified.txt index 1facb0129..464727d39 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.StartProcess_starts_process_and_moves_to_first_task.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.StartProcess_starts_process_and_moves_to_first_task.verified.txt @@ -2,10 +2,20 @@ Activities: [ { ActivityName: Process.HandleEvents, + Tags: [ + { + instance.guid: Guid_1 + } + ], IdFormat: W3C }, { ActivityName: Process.Start, + Tags: [ + { + instance.guid: Guid_1 + } + ], IdFormat: W3C, Status: Ok, Events: [ @@ -32,6 +42,11 @@ }, { ActivityName: Process.StoreEvents, + Tags: [ + { + instance.guid: Guid_1 + } + ], IdFormat: W3C } ], @@ -44,4 +59,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index 8700aac22..d430ad672 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -1,8 +1,13 @@ using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using Altinn.App.Common.Tests; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; using Altinn.App.Core.Features.Action; +using Altinn.App.Core.Helpers.Serialization; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.ProcessTasks; @@ -17,26 +22,29 @@ using FluentAssertions; using Moq; using Newtonsoft.Json; +using Xunit.Abstractions; namespace Altinn.App.Core.Tests.Internal.Process; public sealed class ProcessEngineTest : IDisposable { - private Mock _processReaderMock; - private readonly Mock _profileMock; - private readonly Mock _processNavigatorMock; - private readonly Mock _processEventHandlingDelegatorMock; - private readonly Mock _processEventDispatcherMock; - private readonly Mock _processTaskCleanerMock; + private readonly ITestOutputHelper _output; + private static readonly int _instanceOwnerPartyId = 1337; + private static readonly Guid _instanceGuid = new("00000000-DEAD-BABE-0000-001230000000"); + private static readonly string _instanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}"; + private readonly Mock _processReaderMock = new(); + private readonly Mock _profileMock = new(MockBehavior.Strict); + private readonly Mock _processNavigatorMock = new(MockBehavior.Strict); + private readonly Mock _processEventHandlingDelegatorMock = new(); + private readonly Mock _processEventDispatcherMock = new(); + private readonly Mock _processTaskCleanerMock = new(); + private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _appModelMock = new(MockBehavior.Strict); + private readonly Mock _appMetadataMock = new(MockBehavior.Strict); - public ProcessEngineTest() + public ProcessEngineTest(ITestOutputHelper output) { - _processReaderMock = new(); - _profileMock = new(); - _processNavigatorMock = new(); - _processEventHandlingDelegatorMock = new(); - _processEventDispatcherMock = new(); - _processTaskCleanerMock = new(); + _output = output; } [Fact] @@ -45,6 +53,8 @@ public async Task StartProcess_returns_unsuccessful_when_process_already_started ProcessEngine processEngine = GetProcessEngine(); Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", Process = new ProcessState() { CurrentTask = new ProcessElementInfo() { ElementId = "Task_1" } } }; ProcessStartRequest processStartRequest = new ProcessStartRequest() { Instance = instance }; @@ -57,10 +67,9 @@ public async Task StartProcess_returns_unsuccessful_when_process_already_started [Fact] public async Task StartProcess_returns_unsuccessful_when_no_matching_startevent_found() { - Mock processReaderMock = new(); - processReaderMock.Setup(r => r.GetStartEventIds()).Returns(new List() { "StartEvent_1" }); - ProcessEngine processEngine = GetProcessEngine(processReaderMock); - Instance instance = new Instance(); + _processReaderMock.Setup(r => r.GetStartEventIds()).Returns(new List() { "StartEvent_1" }); + ProcessEngine processEngine = GetProcessEngine(setupProcessReaderMock: false); + Instance instance = new Instance() { Id = _instanceId, AppId = "org/app", }; ProcessStartRequest processStartRequest = new ProcessStartRequest() { Instance = instance, @@ -77,7 +86,12 @@ public async Task StartProcess_returns_unsuccessful_when_no_matching_startevent_ public async Task StartProcess_starts_process_and_moves_to_first_task_without_event_dispatch_when_dryrun() { ProcessEngine processEngine = GetProcessEngine(); - Instance instance = new Instance() { InstanceOwner = new InstanceOwner() { PartyId = "1337" } }; + Instance instance = new Instance() + { + Id = _instanceId, + AppId = "org/app", + InstanceOwner = new InstanceOwner() { PartyId = "1337" } + }; ClaimsPrincipal user = new( new ClaimsIdentity( @@ -104,7 +118,13 @@ public async Task StartProcess_starts_process_and_moves_to_first_task() { TelemetrySink telemetrySink = new(); ProcessEngine processEngine = GetProcessEngine(telemetrySink: telemetrySink); - Instance instance = new Instance() { InstanceOwner = new InstanceOwner() { PartyId = "1337" } }; + Instance instance = new Instance() + { + Id = _instanceId, + AppId = "org/app", + InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], + }; ClaimsPrincipal user = new( new ClaimsIdentity( @@ -126,7 +146,10 @@ public async Task StartProcess_starts_process_and_moves_to_first_task() _processNavigatorMock.Verify(n => n.GetNextTask(It.IsAny(), "StartEvent_1", null), Times.Once); var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = new ProcessElementInfo() @@ -144,6 +167,7 @@ public async Task StartProcess_starts_process_and_moves_to_first_task() { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartEvent.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -165,6 +189,7 @@ public async Task StartProcess_starts_process_and_moves_to_first_task() }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -212,7 +237,13 @@ public async Task StartProcess_starts_process_and_moves_to_first_task() public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefill() { ProcessEngine processEngine = GetProcessEngine(); - Instance instance = new Instance() { InstanceOwner = new InstanceOwner() { PartyId = "1337" } }; + Instance instance = new Instance() + { + Id = _instanceId, + AppId = "org/app", + InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], + }; ClaimsPrincipal user = new( new ClaimsIdentity( @@ -240,7 +271,10 @@ public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefi _processNavigatorMock.Verify(n => n.GetNextTask(It.IsAny(), "StartEvent_1", null), Times.Once); var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = new ProcessElementInfo() @@ -258,6 +292,7 @@ public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefi { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartEvent.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -279,6 +314,7 @@ public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefi }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -324,7 +360,12 @@ public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefi public async Task Next_returns_unsuccessful_when_process_null() { ProcessEngine processEngine = GetProcessEngine(); - Instance instance = new Instance() { Process = null }; + Instance instance = new Instance() + { + Id = _instanceId, + AppId = "org/app", + Process = null + }; ProcessNextRequest processNextRequest = new ProcessNextRequest() { Instance = instance }; ProcessChangeResult result = await processEngine.Next(processNextRequest); result.Success.Should().BeFalse(); @@ -336,7 +377,12 @@ public async Task Next_returns_unsuccessful_when_process_null() public async Task Next_returns_unsuccessful_when_process_currenttask_null() { ProcessEngine processEngine = GetProcessEngine(); - Instance instance = new Instance() { Process = new ProcessState() { CurrentTask = null } }; + Instance instance = new Instance() + { + Id = _instanceId, + AppId = "org/app", + Process = new ProcessState() { CurrentTask = null } + }; ProcessNextRequest processNextRequest = new ProcessNextRequest() { Instance = instance }; ProcessChangeResult result = await processEngine.Next(processNextRequest); result.Success.Should().BeFalse(); @@ -349,7 +395,10 @@ public async Task Next_returns_unsuccessful_unauthorized_when_action_handler_ret { var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = null, @@ -367,10 +416,16 @@ public async Task Next_returns_unsuccessful_unauthorized_when_action_handler_ret errorType: ProcessErrorType.Unauthorized ) ); - ProcessEngine processEngine = GetProcessEngine(null, expectedInstance, [userActionMock.Object]); + ProcessEngine processEngine = GetProcessEngine( + updatedInstance: expectedInstance, + userActions: [userActionMock.Object] + ); Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -402,7 +457,10 @@ public async Task Next_moves_instance_to_next_task_and_produces_instanceevents() { var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = new ProcessElementInfo() @@ -416,10 +474,13 @@ public async Task Next_moves_instance_to_next_task_and_produces_instanceevents() StartEvent = "StartEvent_1" } }; - ProcessEngine processEngine = GetProcessEngine(null, expectedInstance); + ProcessEngine processEngine = GetProcessEngine(updatedInstance: expectedInstance); Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -459,6 +520,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_instanceevents() { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_EndTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -481,6 +543,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_instanceevents() }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -541,7 +604,10 @@ public async Task Next_moves_instance_to_next_task_and_produces_abandon_instance { var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = new ProcessElementInfo() @@ -555,10 +621,13 @@ public async Task Next_moves_instance_to_next_task_and_produces_abandon_instance StartEvent = "StartEvent_1" } }; - ProcessEngine processEngine = GetProcessEngine(null, expectedInstance); + ProcessEngine processEngine = GetProcessEngine(updatedInstance: expectedInstance); Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -599,6 +668,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_abandon_instance { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_AbandonTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -621,6 +691,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_abandon_instance }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_StartTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -681,7 +752,10 @@ public async Task Next_moves_instance_to_end_event_and_ends_proces() { var expectedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { CurrentTask = null, @@ -689,10 +763,13 @@ public async Task Next_moves_instance_to_end_event_and_ends_proces() EndEvent = "EndEvent_1" } }; - ProcessEngine processEngine = GetProcessEngine(null, expectedInstance); + ProcessEngine processEngine = GetProcessEngine(updatedInstance: expectedInstance); Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -727,6 +804,7 @@ public async Task Next_moves_instance_to_end_event_and_ends_proces() { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_EndTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -749,6 +827,7 @@ public async Task Next_moves_instance_to_end_event_and_ends_proces() }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_EndEvent.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -766,6 +845,7 @@ public async Task Next_moves_instance_to_end_event_and_ends_proces() }, new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.Submited.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -820,7 +900,10 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even { Instance instance = new Instance() { + Id = _instanceId, + AppId = "org/app", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -835,8 +918,11 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even }; Instance updatedInstance = new Instance() { + Id = _instanceId, + AppId = "org/app", Org = "ttd", InstanceOwner = new InstanceOwner() { PartyId = "1337" }, + Data = [], Process = new ProcessState() { StartEvent = "StartEvent_1", @@ -854,6 +940,7 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even { new() { + InstanceId = $"{_instanceOwnerPartyId}/{_instanceGuid}", EventType = InstanceEventType.process_AbandonTask.ToString(), InstanceOwnerPartyId = "1337", User = new() @@ -875,7 +962,7 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even } } }; - ProcessEngine processEngine = GetProcessEngine(null, updatedInstance); + ProcessEngine processEngine = GetProcessEngine(updatedInstance: updatedInstance); ProcessStartRequest processStartRequest = new ProcessStartRequest() { Instance = instance, Prefill = prefill, }; Instance result = await processEngine.HandleEventsAndUpdateStorage( processStartRequest.Instance, @@ -902,15 +989,14 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even } private ProcessEngine GetProcessEngine( - Mock? processReaderMock = null, + bool setupProcessReaderMock = true, Instance? updatedInstance = null, List? userActions = null, TelemetrySink? telemetrySink = null ) { - if (processReaderMock == null) + if (setupProcessReaderMock) { - _processReaderMock = new(); _processReaderMock.Setup(r => r.GetStartEventIds()).Returns(new List() { "StartEvent_1" }); _processReaderMock.Setup(r => r.IsProcessTask("StartEvent_1")).Returns(false); _processReaderMock.Setup(r => r.IsEndEvent("Task_1")).Returns(false); @@ -920,10 +1006,6 @@ private ProcessEngine GetProcessEngine( _processReaderMock.Setup(r => r.IsEndEvent("EndEvent_1")).Returns(true); _processReaderMock.Setup(r => r.IsProcessTask("EndEvent_1")).Returns(false); } - else - { - _processReaderMock = processReaderMock; - } _profileMock .Setup(p => p.GetUserProfile(1337)) @@ -987,6 +1069,9 @@ private ProcessEngine GetProcessEngine( _processEventDispatcherMock.Object, _processTaskCleanerMock.Object, new UserActionService(userActions ?? []), + _dataClientMock.Object, + new ModelSerializationService(_appModelMock.Object, telemetrySink?.Object), + _appMetadataMock.Object, telemetrySink?.Object ); } @@ -1000,7 +1085,7 @@ public void Dispose() _processEventDispatcherMock.VerifyNoOtherCalls(); } - private static bool CompareInstance(Instance expected, Instance actual) + private bool CompareInstance(Instance expected, Instance actual) { expected.Process.Started = actual.Process.Started; expected.Process.Ended = actual.Process.Ended; @@ -1012,7 +1097,7 @@ private static bool CompareInstance(Instance expected, Instance actual) return JsonCompare(expected, actual); } - private static bool CompareInstanceEvents(List expected, List actual) + private bool CompareInstanceEvents(List expected, List actual) { for (int i = 0; i < expected.Count; i++) { @@ -1028,7 +1113,7 @@ private static bool CompareInstanceEvents(List expected, List ar.GetLayoutModelForTask(TaskId)) .Returns(new LayoutModel([_mainLayoutComponent, _subLayoutComponent], null)); + _appResourcesMock + .Setup(ar => ar.GetLayoutSet()) + .Returns( + new LayoutSets() + { + Sets = + [ + new LayoutSet() + { + Id = "layoutId", + Tasks = [TaskId], + DataType = DefaultDataType + } + ] + } + ); _httpContextAccessorMock.SetupGet(hca => hca.HttpContext).Returns(new DefaultHttpContext()); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 3650599d8..171f4f2ff 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -93,7 +93,7 @@ public DataElement GetDataElement(DataElementId dataElementId) throw new NotImplementedException(); } - public void AddFormDataElement(string dataType, object data) + public void AddFormDataElement(string dataType, object model) { throw new NotImplementedException(); } From 29f2bb8d77f395ea56ea05d5dda65409ed652cf1 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 14:45:57 +0200 Subject: [PATCH 50/63] Rename DataElementId to DataElementIdentifier --- .../Controllers/DataController.cs | 2 +- .../Features/IInstanceDataAccessor.cs | 6 +- .../Features/IInstanceDataMutator.cs | 2 +- .../Validation/Default/ExpressionValidator.cs | 14 ++-- .../Helpers/DataModel/DataModel.cs | 46 ++++++---- .../Data/CachedInstanceDataAccessor.cs | 83 +++++++++++-------- .../Expressions/ExpressionEvaluator.cs | 10 +-- .../Internal/Expressions/LayoutEvaluator.cs | 6 +- .../Expressions/LayoutEvaluatorState.cs | 20 +++-- .../LayoutEvaluatorStateInitializer.cs | 12 +-- .../Internal/Patch/DataPatchResult.cs | 4 +- .../Internal/Patch/PatchService.cs | 6 +- .../Process/ExpressionsExclusiveGateway.cs | 3 +- ...aElementId.cs => DataElementIdentifier.cs} | 28 ++++--- .../Models/Expressions/ComponentContext.cs | 8 +- .../Models/Layout/DataReference.cs | 2 +- .../Models/Layout/LayoutModel.cs | 44 ++++++---- .../Models/Layout/LayoutSetComponent.cs | 4 +- .../FullTests/Test1/RunTest1.cs | 4 +- .../FullTests/Test2/RunTest2.cs | 14 ++-- .../FullTests/Test3/RunTest3.cs | 16 +++- .../TestUtilities/InstanceDataAccessorFake.cs | 12 +-- 22 files changed, 207 insertions(+), 139 deletions(-) rename src/Altinn.App.Core/Models/{DataElementId.cs => DataElementIdentifier.cs} (62%) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index e7b840cb5..12694d969 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -595,7 +595,7 @@ await UpdatePresentationTextsOnInstance( Instance = res.Ok.Instance, NewDataModels = res .Ok.UpdatedData.Select(d => new DataPatchResponseMultiple.DataModelPairResponse( - d.Id.Guid, + d.Identifier.Guid, d.Data )) .ToList(), diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index ecbeb20c1..b79b22c67 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -17,7 +17,7 @@ public interface IInstanceDataAccessor /// Get the actual data represented in the data element. /// /// The deserialized data model for this data element or an exception for non-form data elements - Task GetFormData(DataElementId dataElementId); + Task GetFormData(DataElementIdentifier dataElementIdentifier); /// /// Gets the raw binary data from a DataElement. @@ -25,11 +25,11 @@ public interface IInstanceDataAccessor /// Form data elements (with appLogic) will get json serialized UTF-8 /// Throws an InvalidOperationException if the data element is not found on the instance /// - Task> GetBinaryData(DataElementId dataElementId); + Task> GetBinaryData(DataElementIdentifier dataElementIdentifier); /// /// Get a data element from an instance by id, /// /// Throws an InvalidOperationException if the data element is not found on the instance - DataElement GetDataElement(DataElementId dataElementId); + DataElement GetDataElement(DataElementIdentifier dataElementIdentifier); } diff --git a/src/Altinn.App.Core/Features/IInstanceDataMutator.cs b/src/Altinn.App.Core/Features/IInstanceDataMutator.cs index a33872eea..fed449af8 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataMutator.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataMutator.cs @@ -30,5 +30,5 @@ public interface IInstanceDataMutator : IInstanceDataAccessor /// /// Actual removal from storage is not done until the instance is saved. /// - void RemoveDataElement(DataElementId dataElementId); + void RemoveDataElement(DataElementIdentifier dataElementIdentifier); } diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index 255684178..5ee817ef0 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -115,17 +115,21 @@ internal async Task> ValidateFormData( var validationIssues = new List(); var expressionValidations = ParseExpressionValidationConfig(validationConfig.RootElement, _logger); - DataElementId dataElementId = dataElement; + DataElementIdentifier dataElementIdentifier = dataElement; foreach (var validationObject in expressionValidations) { - var baseField = new DataReference() { Field = validationObject.Key, DataElementId = dataElementId }; + var baseField = new DataReference() + { + Field = validationObject.Key, + DataElementIdentifier = dataElementIdentifier + }; var resolvedFields = await evaluatorState.GetResolvedKeys(baseField); var validations = validationObject.Value; foreach (var resolvedField in resolvedFields) { if ( hiddenFields.Exists(d => - d.DataElementId == dataElementId + d.DataElementIdentifier == dataElementIdentifier && resolvedField.Field.StartsWith(d.Field, StringComparison.InvariantCulture) ) ) @@ -136,7 +140,7 @@ internal async Task> ValidateFormData( component: null, rowIndices: DataModel.GetRowIndices(resolvedField.Field), rowLength: null, - dataElementId: resolvedField.DataElementId + dataElementIdentifier: resolvedField.DataElementIdentifier ); var positionalArguments = new object[] { resolvedField }; foreach (var validation in validations) @@ -184,7 +188,7 @@ ExpressionValidation validation var validationIssue = new ValidationIssue { Field = resolvedField.Field, - DataElementId = resolvedField.DataElementId.Id.ToString(), + DataElementId = resolvedField.DataElementIdentifier.Id.ToString(), Severity = validation.Severity ?? ValidationIssueSeverity.Error, CustomTextKey = validation.Message, Code = validation.Message, diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index fe686094a..96dbc45e5 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -12,7 +12,7 @@ namespace Altinn.App.Core.Helpers.DataModel; public class DataModel { private readonly IInstanceDataAccessor _dataAccessor; - private readonly Dictionary _dataIdsByType = []; + private readonly Dictionary _dataIdsByType = []; /// /// Constructor that wraps a POCO data model, and gives extra tool for working with the data @@ -40,19 +40,19 @@ public DataModel(IInstanceDataAccessor dataAccessor, ApplicationMetadata appMeta /// public Instance Instance => _dataAccessor.Instance; - private async Task ServiceModel(ModelBinding key, DataElementId defaultDataElementId) + private async Task ServiceModel(ModelBinding key, DataElementIdentifier defaultDataElementIdentifier) { - return (await ServiceModelAndDataElementId(key, defaultDataElementId)).model; + return (await ServiceModelAndDataElementId(key, defaultDataElementIdentifier)).model; } - private async Task<(DataElementId dataElementId, object model)> ServiceModelAndDataElementId( + private async Task<(DataElementIdentifier dataElementId, object model)> ServiceModelAndDataElementId( ModelBinding key, - DataElementId defaultDataElementId + DataElementIdentifier defaultDataElementIdentifier ) { if (key.DataType == null) { - return (defaultDataElementId, await _dataAccessor.GetFormData(defaultDataElementId)); + return (defaultDataElementIdentifier, await _dataAccessor.GetFormData(defaultDataElementIdentifier)); } if (_dataIdsByType.TryGetValue(key.DataType, out var dataElementId)) @@ -80,9 +80,13 @@ DataElementId defaultDataElementId /// "Bedrifter[1].Ansatte.Alder", will fail, because the indicies will be reset /// after an inline index is used /// - public async Task GetModelData(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) + public async Task GetModelData( + ModelBinding key, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes + ) { - var model = await ServiceModel(key, defaultDataElementId); + var model = await ServiceModel(key, defaultDataElementIdentifier); var modelWrapper = new DataModelWrapper(model); return modelWrapper.GetModelData(key.Field, rowIndexes); } @@ -90,9 +94,13 @@ DataElementId defaultDataElementId /// /// Get the count of data elements set in a group (enumerable) /// - public async Task GetModelDataCount(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) + public async Task GetModelDataCount( + ModelBinding key, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes + ) { - var model = await ServiceModel(key, defaultDataElementId); + var model = await ServiceModel(key, defaultDataElementIdentifier); var modelWrapper = new DataModelWrapper(model); return modelWrapper.GetModelDataCount(key.Field, rowIndexes); } @@ -109,11 +117,11 @@ DataElementId defaultDataElementId /// public async Task GetResolvedKeys(DataReference reference) { - var model = await _dataAccessor.GetFormData(reference.DataElementId); + var model = await _dataAccessor.GetFormData(reference.DataElementIdentifier); var modelWrapper = new DataModelWrapper(model); return modelWrapper .GetResolvedKeys(reference.Field) - .Select(k => new DataReference { Field = k, DataElementId = reference.DataElementId }) + .Select(k => new DataReference { Field = k, DataElementIdentifier = reference.DataElementIdentifier }) .ToArray(); } @@ -138,9 +146,13 @@ public async Task GetResolvedKeys(DataReference reference) /// indicies = [1,2] /// => "bedrift[1].ansatte[2].navn" /// - public async Task AddIndexes(ModelBinding key, DataElementId defaultDataElementId, int[]? rowIndexes) + public async Task AddIndexes( + ModelBinding key, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes + ) { - var (dataElementId, serviceModel) = await ServiceModelAndDataElementId(key, defaultDataElementId); + var (dataElementId, serviceModel) = await ServiceModelAndDataElementId(key, defaultDataElementIdentifier); if (serviceModel is null) { throw new DataModelException($"Could not find service model for dataType {key.DataType}"); @@ -148,7 +160,7 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa var modelWrapper = new DataModelWrapper(serviceModel); var field = modelWrapper.AddIndicies(key.Field, rowIndexes); - return new DataReference() { Field = field, DataElementId = dataElementId }; + return new DataReference() { Field = field, DataElementIdentifier = dataElementId }; } /// @@ -156,11 +168,11 @@ public async Task AddIndexes(ModelBinding key, DataElementId defa /// public async Task RemoveField(DataReference reference, RowRemovalOption rowRemovalOption) { - var serviceModel = await _dataAccessor.GetFormData(reference.DataElementId); + var serviceModel = await _dataAccessor.GetFormData(reference.DataElementIdentifier); if (serviceModel is null) { throw new DataModelException( - $"Could not find service model for data element id {reference.DataElementId} to remove values" + $"Could not find service model for data element id {reference.DataElementIdentifier} to remove values" ); } diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index b10dc133b..e4bccc4be 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -35,7 +35,7 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataMutator private readonly LazyCache> _binaryCache = new(); // Data elements to delete (eg RemoveDataElement(dataElementId)), but not yet deleted from instance or storage - private readonly ConcurrentBag _dataElementsToDelete = new(); + private readonly ConcurrentBag _dataElementsToDelete = new(); // Data elements to add (eg AddFormDataElement(dataTypeString, data)), but not yet added to instance or storage private readonly ConcurrentBag<( @@ -68,37 +68,48 @@ ModelSerializationService modelSerializationService public Instance Instance { get; } /// - public async Task GetFormData(DataElementId dataElementId) + public async Task GetFormData(DataElementIdentifier dataElementIdentifier) { return await _formDataCache.GetOrCreate( - dataElementId, + dataElementIdentifier, async () => { - var binaryData = await GetBinaryData(dataElementId); + var binaryData = await GetBinaryData(dataElementIdentifier); - return _modelSerializationService.DeserializeFromStorage(binaryData.Span, GetDataType(dataElementId)); + return _modelSerializationService.DeserializeFromStorage( + binaryData.Span, + GetDataType(dataElementIdentifier) + ); } ); } /// - public async Task> GetBinaryData(DataElementId dataElementId) => + public async Task> GetBinaryData(DataElementIdentifier dataElementIdentifier) => await _binaryCache.GetOrCreate( - dataElementId, + dataElementIdentifier, async () => - await _dataClient.GetDataBytes(_org, _app, _instanceOwnerPartyId, _instanceGuid, dataElementId.Guid) + await _dataClient.GetDataBytes( + _org, + _app, + _instanceOwnerPartyId, + _instanceGuid, + dataElementIdentifier.Guid + ) ); /// - public DataElement GetDataElement(DataElementId dataElementId) + public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { - return Instance.Data.Find(d => d.Id == dataElementId.Id) - ?? throw new InvalidOperationException($"Data element with id {dataElementId.Id} not found in instance"); + return Instance.Data.Find(d => d.Id == dataElementIdentifier.Id) + ?? throw new InvalidOperationException( + $"Data element with id {dataElementIdentifier.Id} not found in instance" + ); } - private DataType GetDataType(DataElementId dataElementId) + private DataType GetDataType(DataElementIdentifier dataElementIdentifier) { - var dataElement = GetDataElement(dataElementId); + var dataElement = GetDataElement(dataElementIdentifier); var appMetadata = _appMetadata.GetApplicationMetadata().Result; var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); if (dataType is null) @@ -153,15 +164,17 @@ ReadOnlyMemory bytes } /// - public void RemoveDataElement(DataElementId dataElementId) + public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) { - var dataElement = Instance.Data.Find(d => d.Id == dataElementId.Id); + var dataElement = Instance.Data.Find(d => d.Id == dataElementIdentifier.Id); if (dataElement is null) { - throw new InvalidOperationException($"Data element with id {dataElementId.Id} not found in instance"); + throw new InvalidOperationException( + $"Data element with id {dataElementIdentifier.Id} not found in instance" + ); } - _dataElementsToDelete.Add(dataElementId); + _dataElementsToDelete.Add(dataElementIdentifier); } public List GetDataElementChanges(bool initializeAltinnRowId) @@ -169,19 +182,19 @@ public List GetDataElementChanges(bool initializeAltinnRowId) var changes = new List(); foreach (var dataElement in Instance.Data) { - DataElementId dataElementId = dataElement; - object? data = _formDataCache.GetCachedValueOrDefault(dataElementId); + DataElementIdentifier dataElementIdentifier = dataElement; + object? data = _formDataCache.GetCachedValueOrDefault(dataElementIdentifier); // Skip data elements that have not been fetched if (data is null) continue; - var dataType = GetDataType(dataElementId); - var previousBinary = _binaryCache.GetCachedValueOrDefault(dataElementId); + var dataType = GetDataType(dataElementIdentifier); + var previousBinary = _binaryCache.GetCachedValueOrDefault(dataElementIdentifier); if (initializeAltinnRowId) { ObjectUtils.InitializeAltinnRowId(data); } var (currentBinary, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); - _binaryCache.Set(dataElementId, currentBinary); + _binaryCache.Set(dataElementIdentifier, currentBinary); if (!currentBinary.Span.SequenceEqual(previousBinary.Span)) { @@ -291,31 +304,31 @@ internal async Task SaveChanges(List changes) /// /// Add or replace existing data element data in the cache /// - internal void SetFormData(DataElementId dataElementId, object data) + internal void SetFormData(DataElementIdentifier dataElementIdentifier, object data) { - var dataType = GetDataType(dataElementId); + var dataType = GetDataType(dataElementIdentifier); if (dataType.AppLogic?.ClassRef is not { } classRef) { - throw new InvalidOperationException($"Data element {dataElementId.Id} don't have app logic"); + throw new InvalidOperationException($"Data element {dataElementIdentifier.Id} don't have app logic"); } if (data.GetType().FullName != classRef) { throw new InvalidOperationException( - $"Data object registered for {dataElementId.Id} is not of type {classRef} as specified in application metadata for data type {dataType.Id}, but {data.GetType().FullName}" + $"Data object registered for {dataElementIdentifier.Id} is not of type {classRef} as specified in application metadata for data type {dataType.Id}, but {data.GetType().FullName}" ); } - _formDataCache.Set(dataElementId, data); + _formDataCache.Set(dataElementIdentifier, data); } /// /// Compatibility function to update both formDataCache and binaryCache as we assume storage has already been updated. /// [Obsolete("Should only be used for actions that set UpdatedDataModels on UserActionResult which is deprecated")] - internal void ReplaceFormDataAssumeSavedToStorage(DataElementId dataElementId, object newModel) + internal void ReplaceFormDataAssumeSavedToStorage(DataElementIdentifier dataElementIdentifier, object newModel) { - SetFormData(dataElementId, newModel); - var (data, _) = _modelSerializationService.SerializeToStorage(newModel, GetDataType(dataElementId)); - _binaryCache.Set(dataElementId, data); + SetFormData(dataElementIdentifier, newModel); + var (data, _) = _modelSerializationService.SerializeToStorage(newModel, GetDataType(dataElementIdentifier)); + _binaryCache.Set(dataElementIdentifier, data); } /// @@ -325,7 +338,7 @@ private sealed class LazyCache { private readonly Dictionary>> _cache = new(); - public async Task GetOrCreate(DataElementId key, Func> valueFactory) + public async Task GetOrCreate(DataElementIdentifier key, Func> valueFactory) { Lazy>? lazyTask; lock (_cache) @@ -339,7 +352,7 @@ public async Task GetOrCreate(DataElementId key, Func> valueFactory) return await lazyTask.Value; } - public void Set(DataElementId key, T data) + public void Set(DataElementIdentifier key, T data) { lock (_cache) { @@ -347,12 +360,12 @@ public void Set(DataElementId key, T data) } } - public T? GetCachedValueOrDefault(DataElementId id) + public T? GetCachedValueOrDefault(DataElementIdentifier identifier) { lock (_cache) { if ( - _cache.TryGetValue(id.Guid, out var lazyTask) + _cache.TryGetValue(identifier.Guid, out var lazyTask) && lazyTask.IsValueCreated && lazyTask.Value.IsCompletedSuccessfully ) diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index c223c6cdc..ab5819fed 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -111,7 +111,7 @@ bool defaultReturn { return await DataModel( new ModelBinding() { Field = dataReference.Field }, - dataReference.DataElementId, + dataReference.DataElementIdentifier, context.RowIndices, state ); @@ -127,17 +127,17 @@ bool defaultReturn $"""Expected ["dataModel", ...] to have 1-2 argument(s), got {args.Length}""" ) }; - return await DataModel(key, context.DataElementId, context.RowIndices, state); + return await DataModel(key, context.DataElementIdentifier, context.RowIndices, state); } private static async Task DataModel( ModelBinding key, - DataElementId defaultDataElementId, + DataElementIdentifier defaultDataElementIdentifier, int[]? indexes, LayoutEvaluatorState state ) { - var data = await state.GetModelData(key, defaultDataElementId, indexes); + var data = await state.GetModelData(key, defaultDataElementIdentifier, indexes); // Only allow IConvertible types to be returned from data model // Objects and arrays should return null @@ -182,7 +182,7 @@ LayoutEvaluatorState state return null; } - return await DataModel(binding, context.DataElementId, context.RowIndices, state); + return await DataModel(binding, context.DataElementIdentifier, context.RowIndices, state); } private static string? Concat(object?[] args) diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index c84ef4291..96cc44569 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -79,7 +79,7 @@ await HiddenFieldsForRemovalRecurs( var rowIndices = context.RowIndices?.Append(index).ToArray() ?? [index]; var indexedBinding = await state.AddInidicies( repGroup.DataModelBindings["group"], - context.DataElementId, + context.DataElementIdentifier, rowIndices ); @@ -166,7 +166,7 @@ ComponentContext context { foreach (var (bindingName, binding) in context.Component.DataModelBindings) { - var value = await state.GetModelData(binding, context.DataElementId, context.RowIndices); + var value = await state.GetModelData(binding, context.DataElementIdentifier, context.RowIndices); if (value is null) { var field = await state.AddInidicies(binding, context); @@ -174,7 +174,7 @@ ComponentContext context new ValidationIssue() { Severity = ValidationIssueSeverity.Error, - DataElementId = field.DataElementId.ToString(), + DataElementId = field.DataElementIdentifier.ToString(), Field = field.Field, Description = $"{field.Field} is required in component with id {context.Component.LayoutId}.{context.Component.PageId}.{context.Component.Id} for binding {bindingName}", diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index b02599e7a..99a2e8301 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -140,9 +140,13 @@ private static bool CompareRowIndexes(int[]? targetRowIndexes, int[]? sourceRowI /// /// Get field from dataModel with key and context /// - public async Task GetModelData(ModelBinding key, DataElementId defaultDataElementId, int[]? indexes) + public async Task GetModelData( + ModelBinding key, + DataElementIdentifier defaultDataElementIdentifier, + int[]? indexes + ) { - return await _dataModel.GetModelData(key, defaultDataElementId, indexes); + return await _dataModel.GetModelData(key, defaultDataElementIdentifier, indexes); } /// @@ -205,21 +209,25 @@ public string GetInstanceContext(string key) /// public async Task AddInidicies(ModelBinding binding, ComponentContext context) { - return await _dataModel.AddIndexes(binding, context.DataElementId, context.RowIndices); + return await _dataModel.AddIndexes(binding, context.DataElementIdentifier, context.RowIndices); } /// /// Return a full dataModelBiding from a context aware binding by adding indexes /// - public async Task AddInidicies(ModelBinding binding, DataElementId dataElementId, int[]? indexes) + public async Task AddInidicies( + ModelBinding binding, + DataElementIdentifier dataElementIdentifier, + int[]? indexes + ) { - return await _dataModel.AddIndexes(binding, dataElementId, indexes); + return await _dataModel.AddIndexes(binding, dataElementIdentifier, indexes); } /// /// This is the wrong abstraction, but used in tests that work /// - internal DataElementId GetDefaultDataElementId() + internal DataElementIdentifier GetDefaultDataElementId() { return _componentModel?.GetDefaultDataElementId(_instanceContext) ?? throw new InvalidOperationException("Component model not loaded"); diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 37d4e0fe6..63c0ee5f1 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -52,9 +52,9 @@ public SingleDataElementAccessor(Instance instance, DataElement dataElement, obj public Instance Instance { get; } - public Task GetFormData(DataElementId dataElementId) + public Task GetFormData(DataElementIdentifier dataElementIdentifier) { - if (dataElementId != _dataElement) + if (dataElementIdentifier != _dataElement) { return Task.FromException( new InvalidOperationException( @@ -65,14 +65,14 @@ public Task GetFormData(DataElementId dataElementId) return Task.FromResult(_data); } - public Task> GetBinaryData(DataElementId dataElementId) + public Task> GetBinaryData(DataElementIdentifier dataElementIdentifier) { return Task.FromException>(new NotImplementedException()); } - public DataElement GetDataElement(DataElementId dataElementId) + public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { - if (dataElementId != _dataElement) + if (dataElementIdentifier != _dataElement) { throw new InvalidOperationException( "Use the new ILayoutEvaluatorStateInitializer interface to support multiple data models and subforms" @@ -102,7 +102,7 @@ ReadOnlyMemory data } // Not implemented - public void RemoveDataElement(DataElementId dataElementId) + public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) { throw new NotImplementedException( "The obsolete LayoutEvaluatorStateInitializer.Init method does not support removing data elements" diff --git a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs index 8442bd914..2a1c7a8e7 100644 --- a/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs +++ b/src/Altinn.App.Core/Internal/Patch/DataPatchResult.cs @@ -33,7 +33,7 @@ public class DataPatchResult /// /// Store a pair with Id and Data /// - /// The data element id + /// The data element id /// The deserialized data - public record DataModelPair(DataElementId Id, object Data); + public record DataModelPair(DataElementIdentifier Identifier, object Data); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index c7fafe014..5dc1d9ecd 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -88,9 +88,9 @@ public async Task> ApplyPatches( }; } - DataElementId dataElementId = dataElement; + DataElementIdentifier dataElementIdentifier = dataElement; - var oldModel = await dataAccessor.GetFormData(dataElementId); // TODO: Fetch data in parallel + var oldModel = await dataAccessor.GetFormData(dataElementIdentifier); // TODO: Fetch data in parallel var oldModelNode = JsonSerializer.SerializeToNode(oldModel); var patchResult = jsonPatch.Apply(oldModelNode); @@ -133,7 +133,7 @@ public async Task> ApplyPatches( DataElement = dataElement, PreviousFormData = oldModel, CurrentFormData = newModel, - PreviousBinaryData = await dataAccessor.GetBinaryData(dataElementId), + PreviousBinaryData = await dataAccessor.GetBinaryData(dataElementIdentifier), CurrentBinaryData = null, } ); diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 5d18ad455..8dcf3880e 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -89,7 +89,8 @@ ProcessGatewayInformation processGatewayInformation dataTypeId = layoutSet?.DataType; } var expression = GetExpressionFromCondition(sequenceFlow.ConditionExpression); - DataElementId dataElement = instance.Data.Find(d => d.DataType == dataTypeId) ?? new DataElementId(); + DataElementIdentifier dataElement = + instance.Data.Find(d => d.DataType == dataTypeId) ?? new DataElementIdentifier(); var componentContext = new ComponentContext( component: null, diff --git a/src/Altinn.App.Core/Models/DataElementId.cs b/src/Altinn.App.Core/Models/DataElementIdentifier.cs similarity index 62% rename from src/Altinn.App.Core/Models/DataElementId.cs rename to src/Altinn.App.Core/Models/DataElementIdentifier.cs index e335d7c28..ef0770eda 100644 --- a/src/Altinn.App.Core/Models/DataElementId.cs +++ b/src/Altinn.App.Core/Models/DataElementIdentifier.cs @@ -3,9 +3,9 @@ namespace Altinn.App.Core.Models; /// -/// Wrapper type for a +/// Wrapper type for a as Guid and string /// -public readonly struct DataElementId : IEquatable +public readonly struct DataElementIdentifier : IEquatable { /// /// The backing field for the parsed guid that identifies a @@ -17,7 +17,7 @@ namespace Altinn.App.Core.Models; /// public string Id { get; } - private DataElementId(Guid guid, string id) + private DataElementIdentifier(Guid guid, string id) { Guid = guid; Id = id; @@ -26,18 +26,18 @@ private DataElementId(Guid guid, string id) /// /// Implicit conversion to allow DataElements to be used as DataElementIds /// - public static implicit operator DataElementId(DataElement dataElement) => + public static implicit operator DataElementIdentifier(DataElement dataElement) => new(Guid.Parse(dataElement.Id), dataElement.Id); /// /// Make the implicit conversion from string (containing a valid guid) to DataElementIdentifier work /// - public static explicit operator DataElementId(string id) => new(Guid.Parse(id), id); + public static explicit operator DataElementIdentifier(string id) => new(Guid.Parse(id), id); /// /// Make the implicit conversion from guid to DataElementIdentifier work /// - public static explicit operator DataElementId(Guid guid) => new(guid, guid.ToString()); + public static explicit operator DataElementIdentifier(Guid guid) => new(guid, guid.ToString()); /// /// Make the ToString method return the ID @@ -50,17 +50,21 @@ public override string ToString() /// public override bool Equals(object? obj) { - return obj is DataElementId other && Equals(other); + return obj is DataElementIdentifier other && Equals(other); } - /// - public static bool operator ==(DataElementId left, DataElementId right) + /// + /// Override as in a record type + /// + public static bool operator ==(DataElementIdentifier left, DataElementIdentifier right) { return left.Equals(right); } - /// - public static bool operator !=(DataElementId left, DataElementId right) + /// + /// Override as in a record type + /// + public static bool operator !=(DataElementIdentifier left, DataElementIdentifier right) { return !left.Equals(right); } @@ -68,7 +72,7 @@ public override bool Equals(object? obj) /// /// Override equality to only compare the guid /// - public bool Equals(DataElementId other) + public bool Equals(DataElementIdentifier other) { return Guid.Equals(other.Guid); } diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 05bf6133b..37b39703b 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -19,11 +19,11 @@ public ComponentContext( BaseComponent? component, int[]? rowIndices, int? rowLength, - DataElementId dataElementId, + DataElementIdentifier dataElementIdentifier, IEnumerable? childContexts = null ) { - DataElementId = dataElementId; + DataElementIdentifier = dataElementIdentifier; Component = component; RowIndices = rowIndices; _rowLength = rowLength; @@ -95,7 +95,7 @@ public async Task GetHiddenRows(LayoutEvaluatorState state) Component, rowIndices, rowLength: hiddenRows.Length, - dataElementId: DataElementId, + dataElementIdentifier: DataElementIdentifier, childContexts: childContexts ); var rowHidden = await ExpressionEvaluator.EvaluateBooleanExpression(state, rowContext, "hiddenRow", false); @@ -121,7 +121,7 @@ public async Task GetHiddenRows(LayoutEvaluatorState state) /// /// The Id of the default data element in this context /// - public DataElementId DataElementId { get; } + public DataElementIdentifier DataElementIdentifier { get; } /// /// Get all children and children of children of this componentContext (not including this) diff --git a/src/Altinn.App.Core/Models/Layout/DataReference.cs b/src/Altinn.App.Core/Models/Layout/DataReference.cs index 02d9a77e5..de901c873 100644 --- a/src/Altinn.App.Core/Models/Layout/DataReference.cs +++ b/src/Altinn.App.Core/Models/Layout/DataReference.cs @@ -13,5 +13,5 @@ public record struct DataReference /// /// The Id of the data element that the field is referencing /// - public required DataElementId DataElementId { get; init; } + public required DataElementIdentifier DataElementIdentifier { get; init; } } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index af584e28f..2bd153c8f 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -88,46 +88,55 @@ await GenerateComponentContextsRecurs( private async Task GenerateComponentContextsRecurs( BaseComponent component, DataModel dataModel, - DataElementId defaultDataElementId, + DataElementIdentifier defaultDataElementIdentifier, int[]? indexes ) { return component switch { SubFormComponent subFormComponent - => await GenerateContextForSubComponent(dataModel, subFormComponent, defaultDataElementId), + => await GenerateContextForSubComponent(dataModel, subFormComponent, defaultDataElementIdentifier), RepeatingGroupComponent repeatingGroupComponent => await GenerateContextForRepeatingGroup( dataModel, repeatingGroupComponent, - defaultDataElementId, + defaultDataElementIdentifier, indexes ), GroupComponent groupComponent - => await GenerateContextForGroup(dataModel, groupComponent, defaultDataElementId, indexes), - _ => new ComponentContext(component, indexes?.Length > 0 ? indexes : null, null, defaultDataElementId, []) + => await GenerateContextForGroup(dataModel, groupComponent, defaultDataElementIdentifier, indexes), + _ + => new ComponentContext( + component, + indexes?.Length > 0 ? indexes : null, + null, + defaultDataElementIdentifier, + [] + ) }; } private async Task GenerateContextForGroup( DataModel dataModel, GroupComponent groupComponent, - DataElementId defaultDataElementId, + DataElementIdentifier defaultDataElementIdentifier, int[]? indexes ) { List children = []; foreach (var child in groupComponent.Children) { - children.Add(await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, indexes)); + children.Add( + await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementIdentifier, indexes) + ); } return new ComponentContext( groupComponent, indexes?.Length > 0 ? indexes : null, null, - defaultDataElementId, + defaultDataElementIdentifier, children ); } @@ -135,7 +144,7 @@ private async Task GenerateContextForGroup( private async Task GenerateContextForRepeatingGroup( DataModel dataModel, RepeatingGroupComponent repeatingGroupComponent, - DataElementId defaultDataElementId, + DataElementIdentifier defaultDataElementIdentifier, int[]? indexes ) { @@ -143,7 +152,7 @@ private async Task GenerateContextForRepeatingGroup( var children = new List(); if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) { - rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementId, indexes) ?? 0; + rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementIdentifier, indexes) ?? 0; foreach (var index in Enumerable.Range(0, rowLength.Value)) { foreach (var child in repeatingGroupComponent.Children) @@ -154,7 +163,12 @@ private async Task GenerateContextForRepeatingGroup( subIndexes[^1] = index; children.Add( - await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, subIndexes) + await GenerateComponentContextsRecurs( + child, + dataModel, + defaultDataElementIdentifier, + subIndexes + ) ); } } @@ -164,7 +178,7 @@ await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, su repeatingGroupComponent, indexes?.Length > 0 ? indexes : null, rowLength, - defaultDataElementId, + defaultDataElementIdentifier, children ); } @@ -172,7 +186,7 @@ await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementId, su private async Task GenerateContextForSubComponent( DataModel dataModel, SubFormComponent subFormComponent, - DataElementId defaultDataElementId + DataElementIdentifier defaultDataElementIdentifier ) { List children = []; @@ -191,10 +205,10 @@ DataElementId defaultDataElementId children.Add(new ComponentContext(subFormComponent, null, null, dataElement, subForms)); } - return new ComponentContext(subFormComponent, null, null, defaultDataElementId, children); + return new ComponentContext(subFormComponent, null, null, defaultDataElementIdentifier, children); } - internal DataElementId GetDefaultDataElementId(Instance instanceContext) + internal DataElementIdentifier GetDefaultDataElementId(Instance instanceContext) { return _defaultLayoutSet.GetDefaultDataElementId(instanceContext); } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs index aa1e32c9a..1846f938b 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs @@ -50,9 +50,9 @@ public PageComponent GetPage(string pageName) public IEnumerable Pages => _pages; /// - /// Get the of the that is default for this layout + /// Get the of the that is default for this layout /// - public DataElementId GetDefaultDataElementId(Instance instance) + public DataElementIdentifier GetDefaultDataElementId(Instance instance) { var dataType = DefaultDataType.Id; return instance.Data.Find(d => d.DataType == dataType) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs index 5d744d1c1..5a362b58b 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test1/RunTest1.cs @@ -55,12 +55,12 @@ public async Task RemoveData_WhenPageExpressionIsTrue() new DataReference() { Field = "some.data.binding3", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference() { Field = "some.data.binding2", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() } ] ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs index c4549c1ee..daaf30f41 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test2/RunTest2.cs @@ -53,32 +53,32 @@ public async Task RemoveWholeGroup() new DataReference { Field = "some.data[0].binding", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference() { Field = "some.data[0].binding2", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference { Field = "some.data[0].binding3", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference { Field = "some.data[1].binding", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference { Field = "some.data[1].binding2", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() }, new DataReference { Field = "some.data[1].binding3", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() } ] ); @@ -124,7 +124,7 @@ public async Task RemoveSingleRow() new DataReference() { Field = "some.data[1].binding2", - DataElementId = state.GetDefaultDataElementId() + DataElementIdentifier = state.GetDefaultDataElementId() } ] ); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs index 5aa87f5f5..a018935f9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -55,7 +55,13 @@ public async Task RemoveRowDataFromGroup() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultDataElementId() }] + [ + new DataReference() + { + Field = "some.data[2]", + DataElementIdentifier = state.GetDefaultDataElementId() + } + ] ); // Verify before removing data @@ -116,7 +122,13 @@ public async Task RemoveRowFromGroup() hidden .Should() .BeEquivalentTo( - [new DataReference() { Field = "some.data[2]", DataElementId = state.GetDefaultDataElementId() }] + [ + new DataReference() + { + Field = "some.data[2]", + DataElementIdentifier = state.GetDefaultDataElementId() + } + ] ); // Verify before removing data diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 171f4f2ff..7be36abd5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -25,7 +25,7 @@ public InstanceDataAccessorFake( Instance.Data ??= new(); } - private readonly Dictionary _dataById = new(); + private readonly Dictionary _dataById = new(); private readonly Dictionary _dataByType = new(); private readonly List> _data = new(); @@ -78,17 +78,17 @@ public void Add(DataElement? dataElement, object data, int maxCount = 1) public Instance Instance { get; } - public Task GetFormData(DataElementId dataElementId) + public Task GetFormData(DataElementIdentifier dataElementIdentifier) { - return Task.FromResult(_dataById[dataElementId]); + return Task.FromResult(_dataById[dataElementIdentifier]); } - public Task> GetBinaryData(DataElementId dataElementId) + public Task> GetBinaryData(DataElementIdentifier dataElementIdentifier) { throw new NotImplementedException(); } - public DataElement GetDataElement(DataElementId dataElementId) + public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { throw new NotImplementedException(); } @@ -108,7 +108,7 @@ ReadOnlyMemory data throw new NotImplementedException(); } - public void RemoveDataElement(DataElementId dataElementId) + public void RemoveDataElement(DataElementIdentifier dataElementIdentifier) { throw new NotImplementedException(); } From 2ae6862194f6ff41a7a43c49d15459fe0f0296d7 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 15:05:33 +0200 Subject: [PATCH 51/63] Fix issues and tests failing after merge --- .../Helpers/Serialization/ModelSerializationService.cs | 2 +- .../Infrastructure/Clients/Storage/DataClient.cs | 2 +- .../Internal/Data/CachedInstanceDataAccessor.cs | 2 +- .../Controllers/ProcessControllerTests.cs | 8 ++++++-- test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs | 5 ++++- test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs | 2 +- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs index e0320d70b..f820585b6 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs @@ -84,7 +84,7 @@ public ReadOnlyMemory SerializeToXml(object model) NewLineHandling = NewLineHandling.None, }; using var memoryStream = new MemoryStream(); - XmlWriter xmlWriter = XmlWriter.Create(memoryStream, xmlWriterSettings); + using XmlWriter xmlWriter = XmlWriter.Create(memoryStream, xmlWriterSettings); XmlSerializer serializer = _xmlSerializer.GetSerializer(modelType); serializer.Serialize(xmlWriter, model); diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 00fd160c1..8087202d9 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -105,7 +105,7 @@ Type type var (data, contentType) = _modelSerializationService.SerializeToStorage(dataToSerialize, dataType); StreamContent streamContent = new StreamContent(new MemoryAsStream(data)); - streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/xml"); + streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); HttpResponseMessage response = await _client.PostAsync(token, apiUrl, streamContent); if (response.IsSuccessStatusCode) diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index e4bccc4be..f7ec34928 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -193,7 +193,7 @@ public List GetDataElementChanges(bool initializeAltinnRowId) { ObjectUtils.InitializeAltinnRowId(data); } - var (currentBinary, contentType) = _modelSerializationService.SerializeToStorage(data, dataType); + var (currentBinary, _) = _modelSerializationService.SerializeToStorage(data, dataType); _binaryCache.Set(dataElementIdentifier, currentBinary); if (!currentBinary.Span.SequenceEqual(previousBinary.Span)) diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs index 3f690cef5..b247bf147 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.cs @@ -311,7 +311,9 @@ public async Task RunProcessNext_DataFromHiddenComponents_GetsRemoved() // Mock pdf generation so that the test does not fail due to pof service not running. var pdfMock = new Mock(MockBehavior.Strict); using var pdfReturnStream = new MemoryStream(); - pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + pdfMock + .Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(pdfReturnStream); OverrideServicesForThisTest = (services) => { services.AddSingleton(pdfMock.Object); @@ -385,7 +387,9 @@ public async Task RunProcessNext_ShadowFields_GetsRemoved(string? saveToDataType // Mock pdf generation so that the test does not fail due to pof service not running. var pdfMock = new Mock(MockBehavior.Strict); using var pdfReturnStream = new MemoryStream(); - pdfMock.Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny())).ReturnsAsync(pdfReturnStream); + pdfMock + .Setup(p => p.GeneratePdf(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(pdfReturnStream); OverrideServicesForThisTest = (services) => { services.AddSingleton(pdfMock.Object); diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index 83e5c5a1c..21f6dc0f2 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using Altinn.App.Api.Tests.Data; using Altinn.App.Core.Extensions; @@ -228,7 +229,7 @@ string dataTypeString Id = dataGuid.ToString(), InstanceGuid = instanceGuid.ToString(), DataType = dataTypeString, - ContentType = "application/xml", + ContentType = contentType, }; Directory.CreateDirectory(dataPath + @"blob"); @@ -273,6 +274,8 @@ Guid dataGuid Directory.CreateDirectory(dataPath + @"blob"); var (serializedBytes, contentType) = _modelSerialization.SerializeToStorage(dataToSerialize, dataType); + + Debug.Assert(contentType == dataElement.ContentType, "Content type should not change when updating data"); await File.WriteAllBytesAsync(Path.Join(dataPath, "blob", dataGuid.ToString()), serializedBytes.ToArray()); dataElement.LastChanged = DateTime.UtcNow; diff --git a/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs b/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs index ef72ae3d2..6e3d93e70 100644 --- a/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs +++ b/test/Altinn.App.Core.Tests/Helpers/MemoryAsStreamTests.cs @@ -5,7 +5,7 @@ namespace Altinn.App.Core.Tests.Helpers; public class MemoryAsStreamTests { - private static byte[] _byteSequence = GenerateNonRepeatingByteArray(); + private static readonly byte[] _byteSequence = GenerateNonRepeatingByteArray(); /// /// For testing class we need to handle a sequence of bytes where errors are From 7ddd6d0817c62a89da7c4b206dfabd3220422a47 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 20:47:57 +0200 Subject: [PATCH 52/63] Rename id=>dataElementId in api also a few final touches and possible test fixes --- .../Controllers/ActionsController.cs | 4 -- .../Controllers/DataController.cs | 3 +- .../Models/DataPatchResponseMultiple.cs | 19 +++++--- .../Telemetry/Telemetry.ModelSerialization.cs | 2 +- .../Events/EventsSubscriptionClient.cs | 2 - .../Controllers/DataController_PatchTests.cs | 4 +- .../Mocks/DataClientMock.cs | 46 ++++++------------- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 2 +- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 2 +- 9 files changed, 33 insertions(+), 51 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index 0f4a7010c..d4cb9cb18 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -5,7 +5,6 @@ using Altinn.App.Core.Features.Action; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; @@ -34,7 +33,6 @@ public class ActionsController : ControllerBase private readonly IValidationService _validationService; private readonly IDataClient _dataClient; private readonly IAppMetadata _appMetadata; - private readonly IAppModel _appModel; private readonly ModelSerializationService _modelSerialization; /// @@ -47,7 +45,6 @@ public ActionsController( IValidationService validationService, IDataClient dataClient, IAppMetadata appMetadata, - IAppModel appModel, ModelSerializationService modelSerialization ) { @@ -57,7 +54,6 @@ ModelSerializationService modelSerialization _validationService = validationService; _dataClient = dataClient; _appMetadata = appMetadata; - _appModel = appModel; _modelSerialization = modelSerialization; } diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 12694d969..b3ec749bc 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -480,7 +480,7 @@ public async Task> PatchFormData( new DataPatchResponse() { ValidationIssues = newResponse.ValidationIssues.ToDictionary(d => d.Source, d => d.Issues), - NewDataModel = newResponse.NewDataModels.First(m => m.Id == dataGuid).Data, + NewDataModel = newResponse.NewDataModels.First(m => m.DataElementId == dataGuid).Data, } ); } @@ -578,7 +578,6 @@ public async Task> PatchFormDataMultiple if (res.Success) { - // TODO: handle added and deleted data elements foreach (var change in res.Ok.ChangedDataElements) { await UpdateDataValuesOnInstance(instance, change.DataElement.DataType, change.CurrentFormData); diff --git a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs index cc3a669f0..e8d54a079 100644 --- a/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs +++ b/src/Altinn.App.Api/Models/DataPatchResponseMultiple.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Altinn.App.Api.Controllers; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; @@ -12,22 +13,28 @@ public class DataPatchResponseMultiple /// /// The validation issues that were found during the patch operation. /// + [JsonPropertyName("validationIssues")] public required List ValidationIssues { get; init; } /// /// The current data in all data models updated by the patch operation. /// + [JsonPropertyName("newDataModels")] public required List NewDataModels { get; init; } /// - /// Pair of Guid and data object. + /// The instance with updated dataElement list. /// - /// The guid of the DataElement - /// The form data of the data element - public record DataModelPairResponse(Guid Id, object Data); + [JsonPropertyName("instance")] + public required Instance Instance { get; init; } /// - /// The instance with updated dataElement list. + /// Pair of Guid and data object. /// - public required Instance Instance { get; init; } + /// The guid of the DataElement + /// The form data of the data element + public record DataModelPairResponse( + [property: JsonPropertyName("dataElementId")] Guid DataElementId, + [property: JsonPropertyName("data")] object Data + ); } diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs index d77eb0ca1..8b4a5b938 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ModelSerialization.cs @@ -13,7 +13,7 @@ partial class Telemetry internal Activity? StartSerializeToJsonActivity(Type typeToSerialize) { - var activity = ActivitySource.StartActivity("SerializationService.SerializeXml"); + var activity = ActivitySource.StartActivity("SerializationService.SerializeJson"); activity?.SetTag("Type", typeToSerialize.FullName); return activity; } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Events/EventsSubscriptionClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Events/EventsSubscriptionClient.cs index 8cef0e9d4..cac3cc038 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Events/EventsSubscriptionClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Events/EventsSubscriptionClient.cs @@ -17,7 +17,6 @@ public class EventsSubscriptionClient : IEventsSubscription { private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; - private readonly PlatformSettings _platformSettings; private readonly GeneralSettings _generalSettings; private readonly HttpClient _client; private readonly IEventSecretCodeProvider _secretCodeProvider; @@ -34,7 +33,6 @@ public EventsSubscriptionClient( ILogger logger ) { - _platformSettings = platformSettings.Value; _generalSettings = generalSettings.Value; httpClient.BaseAddress = new Uri(platformSettings.Value.ApiEventsEndpoint); httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index 306f6566e..cc3ee461e 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -258,7 +258,7 @@ public async Task MultiplePatches_AppliesCorrectly() parsedResponse.NewDataModels.Should().HaveCount(2); var newData = parsedResponse .NewDataModels.Should() - .ContainSingle(d => d.Id == _dataGuid) + .ContainSingle(d => d.DataElementId == _dataGuid) .Which.Data.Should() .BeOfType() .Which.Deserialize()!; @@ -266,7 +266,7 @@ public async Task MultiplePatches_AppliesCorrectly() var newExtraData = parsedResponse .NewDataModels.Should() - .ContainSingle(d => d.Id == extraDataGuid) + .ContainSingle(d => d.DataElementId == extraDataGuid) .Which.Data.Should() .BeOfType() .Which.Deserialize()!; diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index 21f6dc0f2..ca12b59d4 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -358,28 +358,19 @@ Stream stream Directory.CreateDirectory(dataPath + @"blob"); - long filesize; - - using ( - Stream streamToWriteTo = File.Open( - dataPath + @"blob/" + dataGuid, - FileMode.Truncate, - FileAccess.ReadWrite, - FileShare.ReadWrite - ) - ) - { + using var memoryStream = new MemoryStream(); stream.Seek(0, SeekOrigin.Begin); - await stream.CopyToAsync(streamToWriteTo); - streamToWriteTo.Flush(); - filesize = streamToWriteTo.Length; - } + await stream.CopyToAsync(memoryStream); + + var fileData = memoryStream.ToArray(); + await File.WriteAllBytesAsync(dataPath + @"blob/" + dataGuid, fileData); + var dataElement = GetDataElements(org, app, instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid) .FirstOrDefault(de => de.Id == dataGuid.ToString()) ?? throw new Exception($"Data element with id {dataGuid} not found in instance"); - dataElement.Size = filesize; + dataElement.Size = fileData.Length; await WriteDataElementToFile(dataElement, org, app, instanceIdentifier.InstanceOwnerPartyId); return dataElement; @@ -424,24 +415,15 @@ public async Task InsertBinaryData( Directory.CreateDirectory(dataPath + @"blob"); - long filesize; + using var memoryStream = new MemoryStream(); + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(memoryStream); - using ( - Stream streamToWriteTo = File.Open( - dataPath + @"blob/" + dataGuid, - FileMode.OpenOrCreate, - FileAccess.ReadWrite, - FileShare.ReadWrite - ) - ) - { - stream.Seek(0, SeekOrigin.Begin); - await stream.CopyToAsync(streamToWriteTo); - streamToWriteTo.Flush(); - filesize = streamToWriteTo.Length; - } + var fileData = memoryStream.ToArray(); + await File.WriteAllBytesAsync(dataPath + @"blob/" + dataGuid, fileData); + + dataElement.Size = fileData.Length; - dataElement.Size = filesize; await WriteDataElementToFile(dataElement, org, app, instanceOwnerId); return dataElement; diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index e32c41d70..6a0328046 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -5663,7 +5663,7 @@ "DataModelPairResponse": { "type": "object", "properties": { - "id": { + "dataElementId": { "type": "string", "format": "uuid" }, diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 84d7a1c7c..3aad51f75 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -3535,7 +3535,7 @@ components: DataModelPairResponse: type: object properties: - id: + dataElementId: type: string format: uuid data: From a6ca853848fead9cd05bcae87e451d84b736f07d Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 21:49:31 +0200 Subject: [PATCH 53/63] Use async versions of File access methods in DataClientMock --- .../Mocks/DataClientMock.cs | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index ca12b59d4..a7d6d507a 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -126,16 +126,14 @@ Guid dataId return await File.ReadAllBytesAsync(dataPath); } -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async Task> GetBinaryDataList( string org, string app, int instanceOwnerPartyId, Guid instanceGuid ) -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { - var dataElements = GetDataElements(org, app, instanceOwnerPartyId, instanceGuid); + var dataElements = await GetDataElements(org, app, instanceOwnerPartyId, instanceGuid); List list = new(); foreach (DataElement dataElement in dataElements) { @@ -255,22 +253,19 @@ Guid dataGuid ArgumentNullException.ThrowIfNull(dataToSerialize); string dataPath = TestData.GetDataDirectory(org, app, instanceOwnerPartyId, instanceGuid); - DataElement? dataElement = GetDataElements(org, app, instanceOwnerPartyId, instanceGuid) - .FirstOrDefault(de => de.Id == dataGuid.ToString()); + DataElement dataElement = + await GetDataElement(org, app, instanceOwnerPartyId, instanceGuid, dataGuid.ToString()) + ?? throw new Exception( + $"Unable to find data element for org: {org}/{app} party: {instanceOwnerPartyId} instance: {instanceGuid} data: {dataGuid}" + ); + ; var application = await _appMetadata.GetApplicationMetadata(); var dataType = - application.DataTypes.Find(d => d.Id == dataElement?.DataType) + application.DataTypes.Find(d => d.Id == dataElement.DataType) ?? throw new InvalidOperationException( - $"Data type {dataElement?.DataType} not found in applicationmetadata.json" + $"Data type {dataElement.DataType} not found in applicationmetadata.json" ); - if (dataElement == null) - { - throw new Exception( - $"Unable to find data element for org: {org}/{app} party: {instanceOwnerPartyId} instance: {instanceGuid} data: {dataGuid}" - ); - } - Directory.CreateDirectory(dataPath + @"blob"); var (serializedBytes, contentType) = _modelSerialization.SerializeToStorage(dataToSerialize, dataType); @@ -359,16 +354,19 @@ Stream stream Directory.CreateDirectory(dataPath + @"blob"); using var memoryStream = new MemoryStream(); - stream.Seek(0, SeekOrigin.Begin); + stream.Seek(0, SeekOrigin.Begin); await stream.CopyToAsync(memoryStream); var fileData = memoryStream.ToArray(); await File.WriteAllBytesAsync(dataPath + @"blob/" + dataGuid, fileData); - var dataElement = - GetDataElements(org, app, instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid) - .FirstOrDefault(de => de.Id == dataGuid.ToString()) - ?? throw new Exception($"Data element with id {dataGuid} not found in instance"); + var dataElement = await GetDataElement( + org, + app, + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + dataGuid.ToString() + ); dataElement.Size = fileData.Length; await WriteDataElementToFile(dataElement, org, app, instanceIdentifier.InstanceOwnerPartyId); @@ -457,17 +455,13 @@ public async Task LockDataElement(InstanceIdentifier instanceIdenti // 🤬The signature does not take org/app, // but our test data is organized by org/app. (string org, string app) = TestData.GetInstanceOrgApp(instanceIdentifier); - List dataElement = GetDataElements( + DataElement element = await GetDataElement( org, app, instanceIdentifier.InstanceOwnerPartyId, - instanceIdentifier.InstanceGuid + instanceIdentifier.InstanceGuid, + dataGuid.ToString() ); - DataElement? element = dataElement.FirstOrDefault(d => d.Id == dataGuid.ToString()); - if (element is null) - { - throw new Exception("Data element not found."); - } element.Locked = true; await WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); return element; @@ -478,17 +472,15 @@ public async Task UnlockDataElement(InstanceIdentifier instanceIden // 🤬The signature does not take org/app, // but our test data is organized by org/app. (string org, string app) = TestData.GetInstanceOrgApp(instanceIdentifier); - List dataElement = GetDataElements( + DataElement element = await GetDataElement( org, app, instanceIdentifier.InstanceOwnerPartyId, - instanceIdentifier.InstanceGuid + instanceIdentifier.InstanceGuid, + dataGuid.ToString() ); - DataElement? element = dataElement.FirstOrDefault(d => d.Id == dataGuid.ToString()); - if (element is null) - { - throw new Exception("Data element not found."); - } + ; + element.Locked = false; await WriteDataElementToFile(element, org, app, instanceIdentifier.InstanceOwnerPartyId); return element; @@ -513,7 +505,7 @@ int instanceOwnerPartyId await File.WriteAllBytesAsync(dataElementPath, jsonBytes); } - private List GetDataElements(string org, string app, int instanceOwnerId, Guid instanceId) + private async Task> GetDataElements(string org, string app, int instanceOwnerId, Guid instanceId) { string path = TestData.GetDataDirectory(org, app, instanceOwnerId, instanceId); List dataElements = new(); @@ -527,7 +519,7 @@ private List GetDataElements(string org, string app, int instanceOw foreach (string file in files) { - string content = File.ReadAllText(Path.Combine(path, file)); + string content = await File.ReadAllTextAsync(Path.Combine(path, file)); DataElement? dataElement = JsonSerializer.Deserialize(content, _jsonSerializerOptions); if (dataElement != null) @@ -546,4 +538,18 @@ private List GetDataElements(string org, string app, int instanceOw return dataElements; } + + private async Task GetDataElement( + string org, + string app, + int instanceOwnerId, + Guid instanceId, + string dataElementGuid + ) + { + string path = TestData.GetDataDirectory(org, app, instanceOwnerId, instanceId); + string content = await File.ReadAllTextAsync(Path.Combine(path, dataElementGuid + ".json")); + return JsonSerializer.Deserialize(content, _jsonSerializerOptions) + ?? throw new JsonException("Returned null when deserializing data element"); + } } From 165af4663dd151e594b147dece712c12493b9063 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 3 Oct 2024 22:39:31 +0200 Subject: [PATCH 54/63] Reorganize ProcessTaskFinalizer to use CachedInstanceDataAccessor --- .../Common/ProcessTaskFinalizer.cs | 139 ++++-------------- ...sNext_PdfFails_DataIsUnlocked.verified.txt | 18 +++ .../Common/ProcessTaskFinalizerTests.cs | 1 + 3 files changed, 49 insertions(+), 109 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index e2c0f6db4..d3c4dcffe 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -1,12 +1,10 @@ -using System.Globalization; -using System.Reflection; using System.Text.Json; using Altinn.App.Core.Configuration; -using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models; @@ -20,6 +18,7 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; + private readonly IAppModel _appModel; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IOptions _appSettings; private readonly ModelSerializationService _modelSerializer; @@ -30,6 +29,7 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer public ProcessTaskFinalizer( IAppMetadata appMetadata, IDataClient dataClient, + IAppModel appModel, ModelSerializationService modelSerializer, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, IOptions appSettings @@ -39,100 +39,49 @@ IOptions appSettings _dataClient = dataClient; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _appSettings = appSettings; + _appModel = appModel; _modelSerializer = modelSerializer; } /// public async Task Finalize(string taskId, Instance instance) { - ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); - List connectedDataTypes = applicationMetadata.DataTypes.FindAll(dt => dt.TaskId == taskId); - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerializer); - var changedDataElements = await RunRemoveFieldsInModelOnTaskComplete( - instance, - dataAccessor, - taskId, - connectedDataTypes, - language: null - ); - // Save changes to the data elements with app logic that was changed. - await Task.WhenAll( - changedDataElements.Select(async dataElement => - { - var data = await dataAccessor.GetFormData(dataElement); - return _dataClient.UpdateData( - data, - Guid.Parse(instance.Id.Split('/')[1]), - data.GetType(), - instance.Org, - instance.AppId.Split('/')[1], - int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture), - Guid.Parse(dataElement.Id) - ); - }) - ); - } + ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); - private async Task> RunRemoveFieldsInModelOnTaskComplete( - Instance instance, - IInstanceDataAccessor dataAccessor, - string taskId, - List dataTypesToLock, - string? language = null - ) - { - ArgumentNullException.ThrowIfNull(instance.Data); - HashSet modifiedDataElements = []; + List tasks = []; + foreach ( + var dataType in applicationMetadata.DataTypes.Where(dt => + dt.TaskId == taskId && dt.AppLogic?.ClassRef is not null + ) + ) + { + foreach (var dataElement in instance.Data.Where(de => de.DataType == dataType.Id)) + { + tasks.Add(RemoveFieldsOnTaskComplete(dataAccessor, taskId, applicationMetadata, dataElement, dataType)); + } + } + await Task.WhenAll(tasks); - var dataTypesWithLogic = dataTypesToLock.Where(d => !string.IsNullOrEmpty(d.AppLogic?.ClassRef)).ToList(); - await Task.WhenAll( - instance - .Data.Join( - dataTypesWithLogic, - de => de.DataType, - dt => dt.Id, - (de, dt) => (dataElement: de, dataType: dt) - ) - .Select( - async (d) => - { - if ( - await RemoveFieldsOnTaskComplete( - instance, - dataAccessor, - taskId, - dataTypesWithLogic, - d.dataElement, - d.dataType, - language - ) - ) - { - modifiedDataElements.Add(d.dataElement); - } - } - ) - ); - return modifiedDataElements; + var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: false); + await dataAccessor.UpdateInstanceData(); + await dataAccessor.SaveChanges(changes); } - private async Task RemoveFieldsOnTaskComplete( - Instance instance, - IInstanceDataAccessor dataAccessor, + private async Task RemoveFieldsOnTaskComplete( + CachedInstanceDataAccessor dataAccessor, string taskId, - List dataTypesWithLogic, + ApplicationMetadata applicationMetadata, DataElement dataElement, DataType dataType, string? language = null ) { - bool isModified = false; var data = await dataAccessor.GetFormData(dataElement); // remove AltinnRowIds - isModified |= ObjectUtils.RemoveAltinnRowId(data); + ObjectUtils.RemoveAltinnRowId(data); // Remove hidden data before validation, ignore hidden rows. if (_appSettings.Value?.RemoveHiddenData == true) @@ -147,20 +96,17 @@ private async Task RemoveFieldsOnTaskComplete( language ); await LayoutEvaluator.RemoveHiddenData(evaluationState, RowRemovalOption.DeleteRow); - // TODO: Make RemoveHiddenData return a bool indicating if data was removed - isModified = true; } // Remove shadow fields // TODO: Use reflection or code generation instead of JsonSerializer if (dataType.AppLogic?.ShadowFields?.Prefix != null) { - Type saveToModelType = data.GetType(); string serializedData = JsonSerializerIgnorePrefix.Serialize(data, dataType.AppLogic.ShadowFields.Prefix); if (dataType.AppLogic.ShadowFields.SaveToDataType != null) { // Save the shadow fields to another data type - DataType? saveToDataType = dataTypesWithLogic.Find(dt => + DataType? saveToDataType = applicationMetadata.DataTypes.Find(dt => dt.Id == dataType.AppLogic.ShadowFields.SaveToDataType ); if (saveToDataType == null) @@ -169,6 +115,7 @@ private async Task RemoveFieldsOnTaskComplete( $"SaveToDataType {dataType.AppLogic.ShadowFields.SaveToDataType} not found" ); } + Type saveToModelType = _appModel.GetModelType(saveToDataType.AppLogic.ClassRef); object updatedData = JsonSerializer.Deserialize(serializedData, saveToModelType) @@ -176,44 +123,18 @@ private async Task RemoveFieldsOnTaskComplete( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); // Save a new data element with the cleaned data without shadow fields. - Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - string app = instance.AppId.Split("/")[1]; - int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); - var newDataElement = await _dataClient.InsertFormData( - updatedData, - instanceGuid, - saveToModelType, - instance.Org, - app, - instanceOwnerPartyId, - saveToDataType.Id - ); - instance.Data.Add(newDataElement); + dataAccessor.AddFormDataElement(saveToDataType.Id, updatedData); } else { // Remove the shadow fields from the data using JsonSerializer var newData = - JsonSerializer.Deserialize(serializedData, saveToModelType) + JsonSerializer.Deserialize(serializedData, data.GetType()) ?? throw new JsonException( "Could not deserialize back datamodel after removing shadow fields. Data was \"null\"" ); - // Copy all properties with a public setter from newData to data - foreach ( - var propertyInfo in saveToModelType - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(p => p.CanWrite) - ) - { - object? value = propertyInfo.GetValue(newData); - propertyInfo.SetValue(data, value); - } - - isModified = true; // TODO: Detect if modifications were made + dataAccessor.SetFormData(dataElement, newData); } } - - // Save the updated data - return isModified; } } diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index bcad7a72b..0c8966a7f 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -242,6 +242,24 @@ ], IdFormat: W3C }, + { + ActivityName: SerializationService.DeserializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C + }, + { + ActivityName: SerializationService.SerializeXml, + Tags: [ + { + Type: Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models.Skjema + } + ], + IdFormat: W3C + }, { ActivityName: Validation.RunValidator, Tags: [ diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs index f03c5fc28..3a6b63928 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs @@ -27,6 +27,7 @@ public ProcessTaskFinalizerTests() _processTaskFinalizer = new ProcessTaskFinalizer( _appMetadataMock.Object, _dataClientMock.Object, + _appModelMock.Object, new ModelSerializationService(_appModelMock.Object), _layoutEvaluatorStateInitializerMock.Object, _appSettings From a31fba925d45166219ee2b7a2549a724a3d45b19 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Fri, 4 Oct 2024 08:23:07 +0200 Subject: [PATCH 55/63] Use 'ProblemDetails' for new 409 response branch in DataController --- src/Altinn.App.Api/Controllers/DataController.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index b3ec749bc..a1499a355 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -166,7 +166,12 @@ [FromQuery] string dataType if (dataTypeFromMetadata.MaxCount > 0 && existingElements >= dataTypeFromMetadata.MaxCount) { return Conflict( - $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})" + new ProblemDetails + { + Title = "Max count reached", + Detail = $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})", + Status = (int)HttpStatusCode.Conflict + } ); } From 4e62860eaf22937acdf56a48bebbade0d6763507 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Sat, 5 Oct 2024 23:41:59 +0200 Subject: [PATCH 56/63] Various fixes and api cleanup * Refactor DataController and separate out DataElementAccessChecker so that it is easier to reuse logic and return consistent ProblemDetails across APIs * Fix error in compatibility code with ActionController not returning data from UpdatedDataModels when IInstanceDataMutator is not used. * Return Instance from UserAction endpoint in case data elements were added or removed. * Check that instance is active when updating metadata. * Use PatchListItem in multiplePatchEndpoint instead of dictionary for better OpenApi documentation --- .../Controllers/ActionsController.cs | 46 +- .../Controllers/DataController.cs | 406 ++++++++++-------- .../UserDefinedMetadataController.cs | 5 +- .../Helpers/DataElementAccessChecker.cs | 209 +++++++++ .../Helpers/ValidContributorHelper.cs | 47 -- .../Models/DataPatchRequestMultiple.cs | 15 +- .../Models/UserActionResponse.cs | 7 + .../Models/UserAction/UserActionResult.cs | 6 + .../Controllers/ActionsControllerTests.cs | 25 +- .../Controllers/DataController_PatchTests.cs | 2 +- ...ts.cs => DataElementAccessCheckerTests.cs} | 4 +- .../Altinn.App.Api.Tests/OpenApi/swagger.json | 25 +- .../Altinn.App.Api.Tests/OpenApi/swagger.yaml | 19 +- 13 files changed, 544 insertions(+), 272 deletions(-) create mode 100644 src/Altinn.App.Api/Helpers/DataElementAccessChecker.cs delete mode 100644 src/Altinn.App.Api/Helpers/ValidContributorHelper.cs rename test/Altinn.App.Api.Tests/Helpers/{ValidContributorHelperTests.cs => DataElementAccessCheckerTests.cs} (92%) diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index d4cb9cb18..c740ea9cf 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -135,6 +135,7 @@ public async Task> Perform( return new NotFoundObjectResult( new UserActionResponse() { + Instance = instance, Error = new ActionError() { Code = "ActionNotFound", @@ -156,33 +157,14 @@ public async Task> Perform( ProcessErrorType.BadRequest => 400, _ => 500 }, - value: new UserActionResponse() { ClientActions = result.ClientActions, Error = result.Error } - ); - } - - // If the action handler returns UpdatedDataModels, instead of using the dataMutator - // we need to update the dataAccessor with the new data in case it was fetched with DataClient -#pragma warning disable CS0618 // Type or member is obsolete - if (result.UpdatedDataModels is { Count: > 0 }) - { - foreach (var (elementId, newModel) in result.UpdatedDataModels) - { - if (newModel is null) + value: new UserActionResponse() { - continue; + Instance = instance, + ClientActions = result.ClientActions, + Error = result.Error } - - var dataElement = - instance.Data.First(d => d.Id.Equals(elementId, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException( - $"Action handler {actionHandler.GetType().Name} returned an updated data model for a data element that does not exist: {elementId}" - ); - - // update dataAccessor to use the changed data - dataAccessor.ReplaceFormDataAssumeSavedToStorage(dataElement, newModel); - } + ); } -#pragma warning restore CS0618 // Type or member is obsolete var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: true); @@ -199,11 +181,25 @@ public async Task> Perform( ); await saveTask; + var updatedDataModels = changes.ToDictionary(c => c.DataElement.Id, c => c.CurrentFormData); + +#pragma warning disable CS0618 // Type or member is obsolete + if (result.UpdatedDataModels is { Count: > 0 }) + { + foreach (var (elementId, data) in result.UpdatedDataModels) + { + // If the data mutator missed a that was returned with the deprecated UpdatedDataModels + // we still need to return it to the frontend, but we assume it was already saved to storage + updatedDataModels.TryAdd(elementId, data); + } + } +#pragma warning restore CS0618 // Type or member is obsolete return Ok( new UserActionResponse() { + Instance = instance, ClientActions = result.ClientActions, - UpdatedDataModels = changes.ToDictionary(c => c.DataElement.Id, c => c.CurrentFormData), + UpdatedDataModels = updatedDataModels, UpdatedValidationIssues = validationIssues, RedirectUrl = result.RedirectUrl, } diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index a1499a355..d6e2a8f1e 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -131,62 +131,24 @@ [FromQuery] string dataType try { - Application application = await _appMetadata.GetApplicationMetadata(); - - DataType? dataTypeFromMetadata = application.DataTypes.First(e => - e.Id.Equals(dataType, StringComparison.OrdinalIgnoreCase) - ); - - if (dataTypeFromMetadata is null) + var instanceResult = await GetInstanceDataOrError(org, app, instanceOwnerPartyId, instanceGuid, dataType); + if (!instanceResult.Success) { - return BadRequest( - $"Element type {dataType} not allowed for instance {instanceOwnerPartyId}/{instanceGuid}." - ); + return Problem(instanceResult.Error); } - if (!ValidContributorHelper.IsValidContributor(dataTypeFromMetadata, User.GetOrg(), User.GetOrgNumber())) - { - return Forbid(); - } + var (instance, dataTypeFromMetadata) = instanceResult.Ok; - Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance is null) + if (DataElementAccessChecker.GetCreateProblem(instance, dataTypeFromMetadata, User) is { } accessProblem) { - return NotFound($"Did not find instance {instance}"); + return Problem(accessProblem); } - if (!InstanceIsActive(instance)) + if (dataTypeFromMetadata.AppLogic?.ClassRef is not null) { - return Conflict( - $"Cannot upload data for archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}" - ); - } - - int existingElements = instance.Data.Count(d => d.DataType == dataTypeFromMetadata.Id); - if (dataTypeFromMetadata.MaxCount > 0 && existingElements >= dataTypeFromMetadata.MaxCount) - { - return Conflict( - new ProblemDetails - { - Title = "Max count reached", - Detail = $"Element type `{dataType}` has reached its maximum allowed count ({dataTypeFromMetadata.MaxCount})", - Status = (int)HttpStatusCode.Conflict - } - ); - } - - if (dataTypeFromMetadata.AppLogic is not null) - { - if (dataTypeFromMetadata.AppLogic.DisallowUserCreate && !UserHasValidOrgClaim()) - { - return BadRequest($"Element type `{dataType}` cannot be manually created."); - } - - if (dataTypeFromMetadata.AppLogic.ClassRef is not null) - { - return await CreateAppModelData(org, app, instance, dataType); - } + return await CreateAppModelData(org, app, instance, dataType); } + // else, handle the binary upload (bool validationRestrictionSuccess, List errors) = DataRestrictionValidation.CompliesWithDataRestrictions(Request, dataTypeFromMetadata); @@ -310,29 +272,22 @@ public async Task Get( { try { - Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance is null) - { - return NotFound($"Did not find instance {instance}"); - } - - DataElement? dataElement = instance.Data.First(m => - m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) + var instanceResult = await GetInstanceDataOrError( + org, + app, + instanceOwnerPartyId, + instanceGuid, + dataElementGuid: dataGuid ); - - if (dataElement is null) + if (!instanceResult.Success) { - return NotFound("Did not find data element"); + return Problem(instanceResult.Error); } + var (instance, dataType, dataElement) = instanceResult.Ok; - DataType? dataType = await GetDataType(dataElement); - - if (dataType is null) + if (DataElementAccessChecker.GetReaderProblem(instance, dataType, User) is { } accessProblem) { - var error = - $"Could not determine if {dataElement.DataType} requires app logic for application {org}/{app}"; - _logger.LogError(error); - return BadRequest(error); + return Problem(accessProblem); } if (dataType.AppLogic?.ClassRef is not null) @@ -389,35 +344,16 @@ public async Task Put( { try { - Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - - if (!InstanceIsActive(instance)) - { - return Conflict( - $"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}" - ); - } - - DataElement? dataElement = instance.Data.First(m => - m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) - ); - - if (dataElement is null) + var instanceResult = await GetInstanceDataOrError(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); + if (!instanceResult.Success) { - return NotFound("Did not find data element"); + return Problem(instanceResult.Error); } + var (instance, dataType, dataElement) = instanceResult.Ok; - DataType? dataType = await GetDataType(dataElement); - - if (dataType is null) + if (DataElementAccessChecker.GetUpdateProblem(instance, dataType, User) is { } accessProblem) { - _logger.LogError( - "Could not determine if {dataType} requires app logic for application {org}/{app}", - dataType, - org, - app - ); - return BadRequest($"Could not determine if data type {dataType} requires application logic."); + return Problem(accessProblem); } if (dataType.AppLogic?.ClassRef is not null) @@ -471,9 +407,10 @@ public async Task> PatchFormData( [FromQuery] string? language = null ) { + // Validation valid request is performed in the PatchFormDataMultiple method var request = new DataPatchRequestMultiple() { - Patches = new() { [dataGuid] = dataPatchRequest.Patch }, + Patches = new() { new(dataGuid, dataPatchRequest.Patch) }, IgnoredValidators = dataPatchRequest.IgnoredValidators }; var response = await PatchFormDataMultiple(org, app, instanceOwnerPartyId, instanceGuid, request, language); @@ -522,61 +459,31 @@ public async Task> PatchFormDataMultiple { try { - var instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - - if (!InstanceIsActive(instance)) + var instanceResult = await GetInstanceDataOrError( + org, + app, + instanceOwnerPartyId, + instanceGuid, + dataPatchRequest.Patches.Select(p => p.DataElementId) + ); + if (!instanceResult.Success) { - return Conflict( - new ProblemDetails() - { - Title = "Instance is not active", - Detail = - $"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}", - Status = (int)HttpStatusCode.Conflict, - } - ); + return Problem(instanceResult.Error); } + var (instance, dataTypes) = instanceResult.Ok; - foreach (Guid dataGuid in dataPatchRequest.Patches.Keys) + // Verify that the data elements isn't restricted for the user + foreach (var dataType in dataTypes) { - var dataElement = instance.Data.Find(m => m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal)); - - if (dataElement is null) + if (DataElementAccessChecker.GetUpdateProblem(instance, dataType, User) is { } accessProblem) { - return NotFound( - new ProblemDetails() - { - Title = "Did not find data element", - Detail = - $"Data element with id {dataGuid} not found on instance {instanceOwnerPartyId}/{instanceGuid}", - Status = (int)HttpStatusCode.NotFound, - } - ); - } - - var dataType = await GetDataType(dataElement); - - if (dataType?.AppLogic?.ClassRef is null) - { - _logger.LogError( - "Could not determine if {dataType} requires app logic for application {org}/{app}", - dataType?.Id, - org, - app - ); - return BadRequest( - new ProblemDetails() - { - Title = "Could not determine if data type requires application logic", - Detail = $"Could not determine if data type {dataType?.Id} requires application logic." - } - ); + return Problem(accessProblem); } } ServiceResult res = await _patchService.ApplyPatches( instance, - dataPatchRequest.Patches, + dataPatchRequest.Patches.ToDictionary(i => i.DataElementId, i => i.Patch), language, dataPatchRequest.IgnoredValidators ); @@ -614,7 +521,7 @@ await UpdatePresentationTextsOnInstance( { return HandlePlatformHttpException( e, - $"Unable to update data element {string.Join(", ", dataPatchRequest.Patches.Keys)} for instance {instanceOwnerPartyId}/{instanceGuid}" + $"Unable to update data element {string.Join(", ", dataPatchRequest.Patches.Select(i => i.DataElementId))} for instance {instanceOwnerPartyId}/{instanceGuid}" ); } } @@ -640,45 +547,16 @@ [FromRoute] Guid dataGuid { try { - Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - if (instance is null) + var instanceResult = await GetInstanceDataOrError(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); + if (!instanceResult.Success) { - return NotFound("Did not find instance"); + return Problem(instanceResult.Error); } + var (instance, dataType, dataElement) = instanceResult.Ok; - if (!InstanceIsActive(instance)) + if (DataElementAccessChecker.GetDeleteProblem(instance, dataType, dataGuid, User) is { } accessProblem) { - return Conflict( - $"Cannot delete data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}" - ); - } - - DataElement? dataElement = instance.Data.Find(m => - m.Id.Equals(dataGuid.ToString(), StringComparison.Ordinal) - ); - - if (dataElement is null) - { - return NotFound("Did not find data element"); - } - - DataType? dataType = await GetDataType(dataElement); - - if (dataType is null) - { - string errorMsg = - $"Could not determine if {dataElement.DataType} requires app logic for application {org}/{app}"; - _logger.LogError(errorMsg); - return BadRequest(errorMsg); - } - - if ( - dataType.AppLogic?.ClassRef is not null - && dataType.AppLogic.DisallowUserDelete - && !UserHasValidOrgClaim() - ) - { - return BadRequest("Deleting form data is not possible at this moment."); + return Problem(accessProblem); } return await DeleteBinaryData(org, app, instanceOwnerPartyId, instanceGuid, dataGuid); @@ -1077,11 +955,6 @@ private ActionResult HandlePlatformHttpException(PlatformHttpException e, string }; } - private static bool InstanceIsActive(Instance i) - { - return i?.Status?.Archived is null && i?.Status?.SoftDeleted is null && i?.Status?.HardDeleted is null; - } - private ObjectResult Problem(DataPatchError error) { int code = error.ErrorType switch @@ -1104,8 +977,179 @@ private ObjectResult Problem(DataPatchError error) ); } - /// - /// Checks if the current claims principal has a valid `urn:altinn:org` claim - /// - private bool UserHasValidOrgClaim() => !string.IsNullOrWhiteSpace(User.GetOrg()); + private ObjectResult Problem(ProblemDetails error) + { + return StatusCode(error.Status ?? (int)HttpStatusCode.InternalServerError, error); + } + + private async Task< + ServiceResult<(Instance instance, DataType dataType, DataElement dataElement), ProblemDetails> + > GetInstanceDataOrError(string org, string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataElementGuid) + { + try + { + var instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); + if (instance is null) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = $"Did not find instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.NotFound + }; + } + + var dataElement = instance.Data.FirstOrDefault(m => + m.Id.Equals(dataElementGuid.ToString(), StringComparison.Ordinal) + ); + + if (dataElement is null) + { + return new ProblemDetails() + { + Title = "Data Element Not Found", + Detail = + $"Did not find data element {dataElementGuid} on instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.BadRequest + }; + } + + var dataType = await GetDataType(dataElement); + if (dataType is null) + { + return new ProblemDetails() + { + Title = "Data Type Not Found", + Detail = + $"""Could not find the specified data type: "{dataElement.DataType}" in applicationmetadata.json""", + Status = (int)HttpStatusCode.BadRequest + }; + } + + return (instance, dataType, dataElement); + } + catch (PlatformHttpException e) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = e.Message, + Status = (int)e.Response.StatusCode + }; + } + } + + private async Task), ProblemDetails>> GetInstanceDataOrError( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + IEnumerable dataElementGuids + ) + { + try + { + var application = await _appMetadata.GetApplicationMetadata(); + var instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); + if (instance is null) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = $"Did not find instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.NotFound + }; + } + + HashSet dataTypes = []; + + foreach (var dataElementGuid in dataElementGuids) + { + var dataElement = instance.Data.FirstOrDefault(m => + m.Id.Equals(dataElementGuid.ToString(), StringComparison.Ordinal) + ); + + if (dataElement is null) + { + return new ProblemDetails() + { + Title = "Data Element Not Found", + Detail = + $"Did not find data element {dataElementGuid} on instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.NotFound + }; + } + var dataType = application.DataTypes.Find(e => e.Id == dataElement.DataType); + if (dataType is null) + { + return new ProblemDetails() + { + Title = "Data Type Not Found", + Detail = + $"""Data element {dataElement.Id} requires data type "{dataElement.DataType}", but it was not found in applicationmetadata.json""", + Status = (int)HttpStatusCode.InternalServerError + }; + } + dataTypes.Add(dataType); + } + + return (instance, dataTypes); + } + catch (PlatformHttpException e) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = e.Message, + Status = (int)e.Response.StatusCode + }; + } + } + + private async Task> GetInstanceDataOrError( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + string dataTypeId + ) + { + try + { + var instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); + if (instance is null) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = $"Did not find instance {instanceOwnerPartyId}/{instanceGuid}", + Status = (int)HttpStatusCode.NotFound + }; + } + + var application = await _appMetadata.GetApplicationMetadata(); + var dataType = application.DataTypes.Find(e => e.Id == dataTypeId); + + if (dataType is null) + { + return new ProblemDetails + { + Title = "Data Type Not Found", + Detail = $"""Could not find the specified data type: "{dataTypeId}" in applicationmetadata.json""", + Status = (int)HttpStatusCode.BadRequest + }; + } + + return (instance, dataType); + } + catch (PlatformHttpException e) + { + return new ProblemDetails() + { + Title = "Instance Not Found", + Detail = e.Message, + Status = (int)e.Response.StatusCode + }; + } + } } diff --git a/src/Altinn.App.Api/Controllers/UserDefinedMetadataController.cs b/src/Altinn.App.Api/Controllers/UserDefinedMetadataController.cs index 2e5520a75..f98e2e220 100644 --- a/src/Altinn.App.Api/Controllers/UserDefinedMetadataController.cs +++ b/src/Altinn.App.Api/Controllers/UserDefinedMetadataController.cs @@ -3,7 +3,6 @@ using Altinn.App.Api.Infrastructure.Filters; using Altinn.App.Api.Models; using Altinn.App.Core.Constants; -using Altinn.App.Core.Extensions; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; @@ -137,9 +136,9 @@ [FromBody] UserDefinedMetadataDto userDefinedMetadataDto ); } - if (!ValidContributorHelper.IsValidContributor(dataTypeFromMetadata, User.GetOrg(), User.GetOrgNumber())) + if (DataElementAccessChecker.GetUpdateProblem(instance, dataTypeFromMetadata, User) is { } problem) { - return Forbid(); + return StatusCode(problem.Status ?? 500, problem); } List notAllowedKeys = FindNotAllowedKeys(userDefinedMetadataDto, dataTypeFromMetadata); diff --git a/src/Altinn.App.Api/Helpers/DataElementAccessChecker.cs b/src/Altinn.App.Api/Helpers/DataElementAccessChecker.cs new file mode 100644 index 000000000..784b725f9 --- /dev/null +++ b/src/Altinn.App.Api/Helpers/DataElementAccessChecker.cs @@ -0,0 +1,209 @@ +using System.Globalization; +using System.Net; +using System.Security.Claims; +using Altinn.App.Core.Extensions; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Altinn.App.Api.Helpers; + +/// +/// Helper class for validating if a user is a valid contributor to a data type. +/// +/// +/// The concept of inline authorization of valid contributors is not widely used and is likely not the best approach for doing authorization on the data type level, but there is no support for it yet in the policy based authorization, so keeping for now. +/// +internal static class DataElementAccessChecker +{ + internal static bool IsValidContributor(DataType dataType, string? org, int? orgNr) + { + if (dataType.AllowedContributers is null || dataType.AllowedContributers.Count == 0) + { + return true; + } + + foreach (string item in dataType.AllowedContributers) + { + string key = item.Split(':')[0]; + string value = item.Split(':')[1]; + + switch (key.ToLowerInvariant()) + { + case "org": + if (value.Equals(org, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + break; + case "orgno": + if (value.Equals(orgNr?.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + break; + } + } + + return false; + } + + /// + /// Checks if the user has access to read a data element of a given data type on an instance. + /// + /// null for success or ProblemDetails that can be an error response in the Apis + internal static ProblemDetails? GetReaderProblem(Instance instance, DataType dataType, ClaimsPrincipal user) + { + // We don't have any way to restrict reads based on data type yet, so just return null. + // Might be used if we get a concept of internal server only data types or similar. + return null; + } + + // Common checks for create, update and delete + private static ProblemDetails? GetMutationProblem(Instance instance, DataType dataType, ClaimsPrincipal user) + { + if (GetReaderProblem(instance, dataType, user) is { } readProblem) + { + return readProblem; + } + if (!InstanceIsActive(instance)) + { + return new ProblemDetails() + { + Title = "Instance Not Active", + Detail = $"Cannot update data element of archived or deleted instance {instance.Id}", + Status = (int)HttpStatusCode.Conflict + }; + } + if (!IsValidContributor(dataType, user.GetOrg(), user.GetOrgNumber())) + { + return new ProblemDetails + { + Title = "Forbidden", + Detail = "User is not a valid contributor to the data type" + }; + } + return null; + } + + /// + /// Checks if the user has access to create a data element of a given data type on an instance. + /// + /// null for success or ProblemDetails that can be an error response in the Apis + internal static ProblemDetails? GetCreateProblem( + Instance instance, + DataType dataType, + ClaimsPrincipal user, + long? contentLength = null + ) + { + // Run the general mutation checks + if (GetMutationProblem(instance, dataType, user) is { } problemDetails) + { + return problemDetails; + } + + // Verify that we don't exceed the max count for data type on the instance + int existingElements = instance.Data.Count(d => d.DataType == dataType.Id); + if (dataType.MaxCount > 0 && existingElements >= dataType.MaxCount) + { + return new ProblemDetails() + { + Title = "Max Count Exceeded", + Detail = $"Cannot create more than {dataType.MaxCount} data elements of type {dataType.Id}", + Status = (int)HttpStatusCode.Conflict + }; + } + + // Verify that we don't exceed the max size + if (contentLength.HasValue && dataType.MaxSize > 0 && contentLength > dataType.MaxSize) + { + return new ProblemDetails() + { + Title = "Max Size Exceeded", + Detail = + $"Cannot create data element of size {contentLength} which exceeds the max size of {dataType.MaxSize}", + Status = (int)HttpStatusCode.BadRequest + }; + } + + // Verify that only orgs can create data elements when DisallowUserCreate is true + if (dataType.AppLogic?.DisallowUserCreate == true && string.IsNullOrWhiteSpace(user.GetOrg())) + { + return new ProblemDetails() + { + Title = "User Create Disallowed", + Detail = $"Cannot create data element of type {dataType.Id} as it is disallowed by app logic", + Status = (int)HttpStatusCode.BadRequest + }; + } + + return null; + } + + /// + /// Checks if the user has access to mutate a data element of a given data type on an instance. + /// + /// null for success or ProblemDetails that can be an error response in the Apis + internal static ProblemDetails? GetUpdateProblem(Instance instance, DataType dataType, ClaimsPrincipal user) + { + if (GetMutationProblem(instance, dataType, user) is { } problemDetails) + { + return problemDetails; + } + + return null; + } + + /// + /// Checks if the user has access to delete a data element of a given data type on an instance. + /// + /// null for success or ProblemDetails that can be an error response in the Apis + internal static ProblemDetails? GetDeleteProblem( + Instance instance, + DataType dataType, + Guid dataElementId, + ClaimsPrincipal user + ) + { + if (GetMutationProblem(instance, dataType, user) is { } problemDetails) + { + return problemDetails; + } + + // Kept for compatibility with old app logic + // Not sure why this restriction is required, but keeping for now + if (dataType is { AppLogic.ClassRef: not null, MaxCount: 1, MinCount: 1 }) + { + return new ProblemDetails() + { + Title = "Cannot Delete main data element", + Detail = "Cannot delete the only data element of a class with app logic", + Status = (int)HttpStatusCode.BadRequest + }; + } + + if (dataType.AppLogic?.DisallowUserDelete == true && !UserHasValidOrgClaim(user)) + { + return new ProblemDetails() + { + Title = "User Delete Disallowed", + Detail = $"Cannot delete data element of type {dataType.Id} as it is disallowed by app logic", + Status = (int)HttpStatusCode.BadRequest + }; + } + + return null; + } + + private static bool InstanceIsActive(Instance i) + { + return i?.Status?.Archived is null && i?.Status?.SoftDeleted is null && i?.Status?.HardDeleted is null; + } + + /// + /// Checks if the current claims principal has a valid `urn:altinn:org` claim + /// + private static bool UserHasValidOrgClaim(ClaimsPrincipal user) => !string.IsNullOrWhiteSpace(user.GetOrg()); +} diff --git a/src/Altinn.App.Api/Helpers/ValidContributorHelper.cs b/src/Altinn.App.Api/Helpers/ValidContributorHelper.cs deleted file mode 100644 index 4f8e655d3..000000000 --- a/src/Altinn.App.Api/Helpers/ValidContributorHelper.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Globalization; -using Altinn.Platform.Storage.Interface.Models; - -namespace Altinn.App.Api.Helpers; - -/// -/// Helper class for validating if a user is a valid contributor to a data type. -/// -/// -/// The concept of inline authorization of valid contributors is not widely used and is likely not the best approach for doing authorization on the data type level, but there is no support for it yet in the policy based authorization, so keeping for now. -/// -internal static class ValidContributorHelper -{ - internal static bool IsValidContributor(DataType dataType, string? org, int? orgNr) - { - if (dataType.AllowedContributers is null || dataType.AllowedContributers.Count == 0) - { - return true; - } - - foreach (string item in dataType.AllowedContributers) - { - string key = item.Split(':')[0]; - string value = item.Split(':')[1]; - - switch (key.ToLowerInvariant()) - { - case "org": - if (value.Equals(org, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - break; - case "orgno": - if (value.Equals(orgNr?.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - break; - } - } - - return false; - } -} diff --git a/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs b/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs index 75a6061d5..16816f235 100644 --- a/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs +++ b/src/Altinn.App.Api/Models/DataPatchRequestMultiple.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; using Altinn.App.Api.Controllers; -using Altinn.Platform.Storage.Interface.Models; using Json.Patch; namespace Altinn.App.Api.Models; @@ -12,10 +11,20 @@ namespace Altinn.App.Api.Models; public class DataPatchRequestMultiple { /// - /// The Patch operation to perform in a dictionary keyed on the . + /// The Patch operations to perform. /// [JsonPropertyName("patches")] - public required Dictionary Patches { get; init; } + public required List Patches { get; init; } + + /// + /// Item class for the list of patches with Id + /// + /// The guid for the data element this patch applies to + /// The JsonPatch + public record PatchListItem( + [property: JsonPropertyName("dataElementId")] Guid DataElementId, + [property: JsonPropertyName("patch")] JsonPatch Patch + ); /// /// List of validators to ignore during the patch operation. diff --git a/src/Altinn.App.Api/Models/UserActionResponse.cs b/src/Altinn.App.Api/Models/UserActionResponse.cs index b8fa4a0c9..537a5ca6b 100644 --- a/src/Altinn.App.Api/Models/UserActionResponse.cs +++ b/src/Altinn.App.Api/Models/UserActionResponse.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Altinn.App.Core.Models.UserAction; using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Api.Models; @@ -9,6 +10,12 @@ namespace Altinn.App.Api.Models; /// public class UserActionResponse { + /// + /// The instance that might have some values updated by the action + /// + [JsonPropertyName("instance")] + public required Instance Instance { get; set; } + /// /// Data models that have been updated /// diff --git a/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs b/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs index ae68eda31..656d99e95 100644 --- a/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs +++ b/src/Altinn.App.Core/Models/UserAction/UserActionResult.cs @@ -41,6 +41,9 @@ public sealed class UserActionResult /// /// Gets or sets a dictionary of updated data models. Key should be elementId and value should be the updated data model /// + [Obsolete( + "Updates done to data from UserActionContext.DataMutator is tracked and don't need to be returned in the response" + )] public Dictionary? UpdatedDataModels { get; set; } /// @@ -117,6 +120,9 @@ public static UserActionResult RedirectResult(Uri redirectUrl) /// /// /// + [Obsolete( + "Updates done to data from UserActionContext.DataMutator is tracked and don't need to be returned in the response" + )] public void AddUpdatedDataModel(string dataModelId, object dataModel) { if (UpdatedDataModels == null) diff --git a/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs b/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs index 828af761d..65e18ba86 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/ActionsControllerTests.cs @@ -17,6 +17,9 @@ namespace Altinn.App.Api.Tests.Controllers; public class ActionsControllerTests : ApiTestBase, IClassFixture> { + private static readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions( + JsonSerializerDefaults.Web + ); private readonly ITestOutputHelper _outputHelper; public ActionsControllerTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) @@ -194,6 +197,7 @@ public async Task Perform_returns_200_if_action_succeeded() var content = await response.Content.ReadAsStringAsync(); var expectedString = """ { + "instance": {}, "updatedDataModels": {}, "updatedValidationIssues": {}, "clientActions": [ @@ -205,7 +209,18 @@ public async Task Perform_returns_200_if_action_succeeded() "error": null } """; - CompareResult(expectedString, content); + CompareResult( + expectedString, + content, + mutator: actionResponse => + { + // Don't compare the instance object + if (actionResponse != null) + { + actionResponse.Instance = new(); + } + } + ); } [Fact] @@ -339,12 +354,14 @@ public async Task Perform_returns_404_if_action_implementation_not_found() } //TODO: replace this assertion with a proper one once fluentassertions has a json compare feature scheduled for v7 https://github.com/fluentassertions/fluentassertions/issues/2205 - private void CompareResult(string expectedString, string actualString) + private void CompareResult(string expectedString, string actualString, Action? mutator = null) { _outputHelper.WriteLine($"Expected: {expectedString}"); _outputHelper.WriteLine($"Actual: {actualString}"); - T? expected = JsonSerializer.Deserialize(expectedString); - T? actual = JsonSerializer.Deserialize(actualString); + T? expected = JsonSerializer.Deserialize(expectedString, _jsonSerializerOptions); + T? actual = JsonSerializer.Deserialize(actualString, _jsonSerializerOptions); + mutator?.Invoke(actual); + mutator?.Invoke(expected); actual.Should().BeEquivalentTo(expected); } } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs index cc3ee461e..2c181af58 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PatchTests.cs @@ -243,7 +243,7 @@ public async Task MultiplePatches_AppliesCorrectly() ); var request = new DataPatchRequestMultiple() { - Patches = new Dictionary { [_dataGuid] = patch, [extraDataGuid] = patch2, }, + Patches = new() { new(_dataGuid, patch), new(extraDataGuid, patch2), }, IgnoredValidators = [] }; diff --git a/test/Altinn.App.Api.Tests/Helpers/ValidContributorHelperTests.cs b/test/Altinn.App.Api.Tests/Helpers/DataElementAccessCheckerTests.cs similarity index 92% rename from test/Altinn.App.Api.Tests/Helpers/ValidContributorHelperTests.cs rename to test/Altinn.App.Api.Tests/Helpers/DataElementAccessCheckerTests.cs index 807cf00d7..a931fe35f 100644 --- a/test/Altinn.App.Api.Tests/Helpers/ValidContributorHelperTests.cs +++ b/test/Altinn.App.Api.Tests/Helpers/DataElementAccessCheckerTests.cs @@ -4,7 +4,7 @@ namespace Altinn.App.Api.Tests.Helpers; -public class ValidContributorHelperTests +public class DataElementAccessCheckerTests { [Theory] [InlineData(null, null, null, true)] // No allowed contributors, should be true @@ -32,7 +32,7 @@ bool expectedResult }; // Act - bool result = ValidContributorHelper.IsValidContributor(dataType, org, orgNr); + bool result = DataElementAccessChecker.IsValidContributor(dataType, org, orgNr); // Assert result.Should().Be(expectedResult); diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.json b/test/Altinn.App.Api.Tests/OpenApi/swagger.json index 6a0328046..b7656d86d 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.json +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.json @@ -5701,9 +5701,9 @@ "type": "object", "properties": { "patches": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/JsonPatch" + "type": "array", + "items": { + "$ref": "#/components/schemas/PatchListItem" }, "nullable": true }, @@ -6373,6 +6373,19 @@ }, "additionalProperties": false }, + "PatchListItem": { + "type": "object", + "properties": { + "dataElementId": { + "type": "string", + "format": "uuid" + }, + "patch": { + "$ref": "#/components/schemas/JsonPatch" + } + }, + "additionalProperties": false + }, "PatchOperation": { "type": "object", "properties": { @@ -6963,8 +6976,14 @@ "additionalProperties": false }, "UserActionResponse": { + "required": [ + "instance" + ], "type": "object", "properties": { + "instance": { + "$ref": "#/components/schemas/Instance" + }, "updatedDataModels": { "type": "object", "additionalProperties": { }, diff --git a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml index 3aad51f75..a60edab50 100644 --- a/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml +++ b/test/Altinn.App.Api.Tests/OpenApi/swagger.yaml @@ -3562,9 +3562,9 @@ components: type: object properties: patches: - type: object - additionalProperties: - $ref: '#/components/schemas/JsonPatch' + type: array + items: + $ref: '#/components/schemas/PatchListItem' nullable: true ignoredValidators: type: array @@ -4047,6 +4047,15 @@ components: subUnit: type: boolean additionalProperties: false + PatchListItem: + type: object + properties: + dataElementId: + type: string + format: uuid + patch: + $ref: '#/components/schemas/JsonPatch' + additionalProperties: false PatchOperation: type: object properties: @@ -4473,8 +4482,12 @@ components: nullable: true additionalProperties: false UserActionResponse: + required: + - instance type: object properties: + instance: + $ref: '#/components/schemas/Instance' updatedDataModels: type: object additionalProperties: { } From 1faedd2da0f4fa299c46c8b7366406ce774985d8 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Mon, 7 Oct 2024 08:56:21 +0200 Subject: [PATCH 57/63] Fix sonar issues --- src/Altinn.App.Api/Controllers/DataController.cs | 6 +++--- src/Altinn.App.Core/Models/DataElementIdentifier.cs | 12 ++++++------ .../Models/UserAction/UserActionContext.cs | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index d6e2a8f1e..3f63f7f65 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -349,7 +349,7 @@ public async Task Put( { return Problem(instanceResult.Error); } - var (instance, dataType, dataElement) = instanceResult.Ok; + var (instance, dataType, _) = instanceResult.Ok; if (DataElementAccessChecker.GetUpdateProblem(instance, dataType, User) is { } accessProblem) { @@ -410,7 +410,7 @@ public async Task> PatchFormData( // Validation valid request is performed in the PatchFormDataMultiple method var request = new DataPatchRequestMultiple() { - Patches = new() { new(dataGuid, dataPatchRequest.Patch) }, + Patches = [new(dataGuid, dataPatchRequest.Patch)], IgnoredValidators = dataPatchRequest.IgnoredValidators }; var response = await PatchFormDataMultiple(org, app, instanceOwnerPartyId, instanceGuid, request, language); @@ -552,7 +552,7 @@ [FromRoute] Guid dataGuid { return Problem(instanceResult.Error); } - var (instance, dataType, dataElement) = instanceResult.Ok; + var (instance, dataType, _) = instanceResult.Ok; if (DataElementAccessChecker.GetDeleteProblem(instance, dataType, dataGuid, User) is { } accessProblem) { diff --git a/src/Altinn.App.Core/Models/DataElementIdentifier.cs b/src/Altinn.App.Core/Models/DataElementIdentifier.cs index ef0770eda..4cffb450a 100644 --- a/src/Altinn.App.Core/Models/DataElementIdentifier.cs +++ b/src/Altinn.App.Core/Models/DataElementIdentifier.cs @@ -47,12 +47,6 @@ public override string ToString() return Id; } - /// - public override bool Equals(object? obj) - { - return obj is DataElementIdentifier other && Equals(other); - } - /// /// Override as in a record type /// @@ -69,6 +63,12 @@ public override bool Equals(object? obj) return !left.Equals(right); } + /// + public override bool Equals(object? obj) + { + return obj is DataElementIdentifier other && Equals(other); + } + /// /// Override equality to only compare the guid /// diff --git a/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs b/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs index 602e4964c..d2caf7c64 100644 --- a/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs +++ b/src/Altinn.App.Core/Models/UserAction/UserActionContext.cs @@ -28,7 +28,7 @@ public UserActionContext( DataMutator = dataMutator; UserId = userId; ButtonId = buttonId; - ActionMetadata = actionMetadata ?? new Dictionary(); + ActionMetadata = actionMetadata ?? []; Language = language; } @@ -54,7 +54,7 @@ public UserActionContext( DataMutator = null!; UserId = userId; ButtonId = buttonId; - ActionMetadata = actionMetadata ?? new Dictionary(); + ActionMetadata = actionMetadata ?? []; Language = language; } From eab271b14de420dae275ea69cf770d15fa59f14e Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 8 Oct 2024 11:00:44 +0200 Subject: [PATCH 58/63] Use new DataProcessing interface in PUT * use IInstanceMutator * Make IInstanceMutator update dataValues and PresentationTexts and remove from places that uses it --- .../Controllers/ActionsController.cs | 18 ++- .../Controllers/DataController.cs | 123 +++++++++--------- .../Controllers/ProcessController.cs | 8 +- .../Controllers/ValidateController.cs | 16 ++- .../Features/IInstanceDataAccessor.cs | 6 + .../Wrappers/FormDataValidatorWrapper.cs | 5 + src/Altinn.App.Core/Helpers/JsonHelper.cs | 1 + .../ModelSerializationService.cs | 76 ++++++----- .../Data/CachedInstanceDataAccessor.cs | 93 +++++++++---- .../LayoutEvaluatorStateInitializer.cs | 30 ++++- .../Internal/Patch/IPatchService.cs | 16 ++- .../Internal/Patch/PatchService.cs | 103 ++++++++------- .../Internal/Process/ProcessEngine.cs | 7 +- .../Internal/Process/ProcessNavigator.cs | 5 + .../Common/ProcessTaskFinalizer.cs | 14 +- .../Controllers/DataController_PutTests.cs | 1 + .../ValidationServiceTests.cs | 5 + .../Internal/Patch/PatchServiceTests.cs | 3 + .../ExpressionsExclusiveGatewayTests.cs | 3 + .../Internal/Process/ProcessEngineTest.cs | 3 + .../Internal/Process/ProcessNavigatorTests.cs | 3 + .../Common/ProcessTaskFinalizerTests.cs | 3 + .../FullTests/SubForm/SubFormTests.cs | 2 +- .../TestUtilities/InstanceDataAccessorFake.cs | 21 ++- 24 files changed, 378 insertions(+), 187 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ActionsController.cs b/src/Altinn.App.Api/Controllers/ActionsController.cs index c740ea9cf..f2bb4abf9 100644 --- a/src/Altinn.App.Api/Controllers/ActionsController.cs +++ b/src/Altinn.App.Api/Controllers/ActionsController.cs @@ -126,9 +126,15 @@ public async Task> Perform( return Forbid(); } - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); + var dataMutator = new CachedInstanceDataAccessor( + instance, + _dataClient, + _instanceClient, + _appMetadata, + _modelSerialization + ); UserActionContext userActionContext = - new(dataAccessor, userId.Value, actionRequest.ButtonId, actionRequest.Metadata, language); + new(dataMutator, userId.Value, actionRequest.ButtonId, actionRequest.Metadata, language); IUserAction? actionHandler = _userActionService.GetActionHandler(action); if (actionHandler == null) { @@ -166,15 +172,15 @@ public async Task> Perform( ); } - var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: true); + var changes = dataMutator.GetDataElementChanges(initializeAltinnRowId: true); - await dataAccessor.UpdateInstanceData(); + await dataMutator.UpdateInstanceData(changes); - var saveTask = dataAccessor.SaveChanges(changes); + var saveTask = dataMutator.SaveChanges(changes); var validationIssues = await GetIncrementalValidations( instance, - dataAccessor, + dataMutator, changes, actionRequest.IgnoredValidators, language diff --git a/src/Altinn.App.Api/Controllers/DataController.cs b/src/Altinn.App.Api/Controllers/DataController.cs index 3f63f7f65..13e8c9ff0 100644 --- a/src/Altinn.App.Api/Controllers/DataController.cs +++ b/src/Altinn.App.Api/Controllers/DataController.cs @@ -54,6 +54,7 @@ public class DataController : ControllerBase private readonly IFileValidationService _fileValidationService; private readonly IFeatureManager _featureManager; private readonly IPatchService _patchService; + private readonly ModelSerializationService _modelDeserializer; private const long REQUEST_SIZE_LIMIT = 2000 * 1024 * 1024; @@ -73,6 +74,7 @@ public class DataController : ControllerBase /// Service used to analyse files uploaded. /// Service used to validate files uploaded. /// Service for applying a json patch to a json serializable object + /// Service for serializing and deserializing models public DataController( ILogger logger, IInstanceClient instanceClient, @@ -86,7 +88,8 @@ public DataController( IFileValidationService fileValidationService, IAppMetadata appMetadata, IFeatureManager featureManager, - IPatchService patchService + IPatchService patchService, + ModelSerializationService modelDeserializer ) { _logger = logger; @@ -103,6 +106,7 @@ IPatchService patchService _fileValidationService = fileValidationService; _featureManager = featureManager; _patchService = patchService; + _modelDeserializer = modelDeserializer; } /// @@ -349,7 +353,7 @@ public async Task Put( { return Problem(instanceResult.Error); } - var (instance, dataType, _) = instanceResult.Ok; + var (instance, dataType, dataElement) = instanceResult.Ok; if (DataElementAccessChecker.GetUpdateProblem(instance, dataType, User) is { } accessProblem) { @@ -358,7 +362,7 @@ public async Task Put( if (dataType.AppLogic?.ClassRef is not null) { - return await PutFormData(org, app, instance, dataGuid, dataType, language); + return await PutFormData(instance, dataElement, dataType, language); } (bool validationRestrictionSuccess, List errors) = @@ -490,16 +494,6 @@ public async Task> PatchFormDataMultiple if (res.Success) { - foreach (var change in res.Ok.ChangedDataElements) - { - await UpdateDataValuesOnInstance(instance, change.DataElement.DataType, change.CurrentFormData); - await UpdatePresentationTextsOnInstance( - instance, - change.DataElement.DataType, - change.CurrentFormData - ); - } - return Ok( new DataPatchResponseMultiple() { @@ -842,68 +836,79 @@ private async Task PutBinaryData(int instanceOwnerPartyId, Guid in } private async Task PutFormData( - string org, - string app, Instance instance, - Guid dataGuid, + DataElement dataElement, DataType dataType, string? language ) { - int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); - - string classRef = dataType.AppLogic.ClassRef; - Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); - - ModelDeserializer deserializer = new ModelDeserializer(_logger, _appModel.GetModelType(classRef)); - object? serviceModel = await deserializer.DeserializeAsync(Request.Body, Request.ContentType); - - if (!string.IsNullOrEmpty(deserializer.Error)) + var deserializationResult = await _modelDeserializer.DeserializeSingleFromStream( + Request.Body, + Request.ContentType, + dataType + ); + if (!deserializationResult.Success) { - return BadRequest(deserializer.Error); + return Problem(deserializationResult.Error); } - if (serviceModel is null) - { - return BadRequest("No data found in content"); - } + var serviceModel = deserializationResult.Ok; - Dictionary? changedFields = await JsonHelper.ProcessDataWriteWithDiff( + var dataMutator = new CachedInstanceDataAccessor( instance, - dataGuid, - serviceModel, - language, - _dataProcessors, - _logger + _dataClient, + _instanceClient, + _appMetadata, + _modelDeserializer ); - - await UpdatePresentationTextsOnInstance(instance, dataType.Id, serviceModel); - await UpdateDataValuesOnInstance(instance, dataType.Id, serviceModel); - - ObjectUtils.InitializeAltinnRowId(serviceModel); - ObjectUtils.PrepareModelForXmlStorage(serviceModel); - - // Save Formdata to database - DataElement updatedDataElement = await _dataClient.UpdateData( - serviceModel, - instanceGuid, - _appModel.GetModelType(classRef), - org, - app, - instanceOwnerPartyId, - dataGuid + // Get the previous service model for dataProcessing to work + var oldServiceModel = await dataMutator.GetFormData(dataElement); + // Set the new service model so that dataAccessors see the new state + dataMutator.SetFormData(dataElement, serviceModel); + + List changesFromRequest = + [ + new() + { + DataElement = dataElement, + PreviousFormData = oldServiceModel, + CurrentFormData = serviceModel, + } + ]; + + // Run data processors keeping track of changes for diff return + var jsonBeforeDataProcessors = JsonSerializer.Serialize(serviceModel); + await _patchService.RunDataProcessors( + dataMutator, + changesFromRequest, + instance.Process.CurrentTask.ElementId, + language ); + var jsonAfterDataProcessors = JsonSerializer.Serialize(serviceModel); + + // Save changes + var changesAfterDataProcessors = dataMutator.GetDataElementChanges(initializeAltinnRowId: true); + await dataMutator.UpdateInstanceData(changesAfterDataProcessors); + await dataMutator.SaveChanges(changesAfterDataProcessors); - SelfLinkHelper.SetDataAppSelfLinks(instanceOwnerPartyId, instanceGuid, updatedDataElement, Request); + //set self links + int instanceOwnerPartyId = int.Parse(instance.InstanceOwner.PartyId, CultureInfo.InvariantCulture); + Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]); + SelfLinkHelper.SetDataAppSelfLinks(instanceOwnerPartyId, instanceGuid, dataElement, Request); + string dataUrl = dataElement.SelfLinks.Apps; - string dataUrl = updatedDataElement.SelfLinks.Apps; - if (changedFields is not null) + if (jsonBeforeDataProcessors != jsonAfterDataProcessors) { - CalculationResult calculationResult = new(updatedDataElement) { ChangedFields = changedFields }; - return Ok(calculationResult); + // Return the changes caused by the data processors + var changedFields = JsonHelper.FindChangedFields(jsonBeforeDataProcessors, jsonAfterDataProcessors); + if (changedFields.Count > 0) + { + CalculationResult calculationResult = new(dataElement) { ChangedFields = changedFields }; + return Ok(calculationResult); + } } - return Created(dataUrl, updatedDataElement); + return Created(dataUrl, dataElement); } private async Task UpdatePresentationTextsOnInstance(Instance instance, string dataType, object serviceModel) @@ -1079,6 +1084,7 @@ IEnumerable dataElementGuids Status = (int)HttpStatusCode.NotFound }; } + var dataType = application.DataTypes.Find(e => e.Id == dataElement.DataType); if (dataType is null) { @@ -1090,6 +1096,7 @@ IEnumerable dataElementGuids Status = (int)HttpStatusCode.InternalServerError }; } + dataTypes.Add(dataType); } diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 7c7bdcc0d..45325e705 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -249,7 +249,13 @@ [FromRoute] Guid instanceGuid string? language ) { - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _instanceClient, + _appMetadata, + _modelSerialization + ); var validationIssues = await _validationService.ValidateInstanceAtTask( instance, dataAccessor, diff --git a/src/Altinn.App.Api/Controllers/ValidateController.cs b/src/Altinn.App.Api/Controllers/ValidateController.cs index f32a96d76..a46b6d284 100644 --- a/src/Altinn.App.Api/Controllers/ValidateController.cs +++ b/src/Altinn.App.Api/Controllers/ValidateController.cs @@ -80,7 +80,13 @@ public async Task ValidateInstance( try { - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _instanceClient, + _appMetadata, + _modelSerialization + ); var ignoredSources = ignoredValidators?.Split(',').ToList(); List messages = await _validationService.ValidateInstanceAtTask( instance, @@ -155,7 +161,13 @@ public async Task ValidateData( throw new ValidationException("Unknown element type."); } - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerialization); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _instanceClient, + _appMetadata, + _modelSerialization + ); // Run validations for all data elements, but only return the issues for the specific data element var issues = await _validationService.ValidateInstanceAtTask( diff --git a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs index b79b22c67..48b045d85 100644 --- a/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Features/IInstanceDataAccessor.cs @@ -32,4 +32,10 @@ public interface IInstanceDataAccessor /// /// Throws an InvalidOperationException if the data element is not found on the instance DataElement GetDataElement(DataElementIdentifier dataElementIdentifier); + + /// + /// Get the dataType of a data element. + /// + /// Throws an InvalidOperationException if the data element is not found on the instance + DataType GetDataType(DataElementIdentifier dataElementIdentifier); } diff --git a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs index 177b094c0..e95ba0073 100644 --- a/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs +++ b/src/Altinn.App.Core/Features/Validation/Wrappers/FormDataValidatorWrapper.cs @@ -37,6 +37,11 @@ public async Task> Validate( var validateAllElements = _formDataValidator.DataType == "*"; foreach (var dataElement in instance.Data) { + var dataType = instanceDataAccessor.GetDataType(dataElement); + if (dataType.AppLogic?.ClassRef == null) + { + continue; + } if (!validateAllElements && _formDataValidator.DataType != dataElement.DataType) { continue; diff --git a/src/Altinn.App.Core/Helpers/JsonHelper.cs b/src/Altinn.App.Core/Helpers/JsonHelper.cs index 92fa5775e..733951544 100644 --- a/src/Altinn.App.Core/Helpers/JsonHelper.cs +++ b/src/Altinn.App.Core/Helpers/JsonHelper.cs @@ -14,6 +14,7 @@ public static class JsonHelper /// /// Run DataProcessWrite returning the dictionary of the changed fields. /// + [Obsolete("Will be removed in v9")] public static async Task?> ProcessDataWriteWithDiff( Instance instance, Guid dataGuid, diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs index f820585b6..5fa1b5156 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs @@ -5,7 +5,10 @@ using System.Xml.Serialization; using Altinn.App.Core.Features; using Altinn.App.Core.Internal.AppModel; +using Altinn.App.Core.Models.Result; using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; namespace Altinn.App.Core.Helpers.Serialization; @@ -104,41 +107,44 @@ public ReadOnlyMemory SerializeToJson(object model) return json; } - // public async Task> DeserializeSingleFromRequest( - // Stream body, - // string? contentType, - // DataType dataType - // ) - // { - // using var memoryStream = new MemoryStream(); - // await body.CopyToAsync(memoryStream); - // if (!memoryStream.TryGetBuffer(out var segment)) - // { - // throw new InvalidOperationException("Failed to get buffer from memory stream"); - // } - // - // var modelType = GetModelTypeForDataType(dataType); - // object model; - // if (contentType?.Contains("application/xml") ?? true) // default to xml if no content type is provided - // { - // model = DeserializeXml(segment, modelType); - // } - // else if (contentType.Contains("application/json")) - // { - // model = DeserializeJson(segment, modelType); - // } - // else - // { - // return new ProblemDetails() - // { - // Title = "Unsupported content type", - // Detail = $"Content type {contentType} is not supported for deserialization", - // Status = StatusCodes.Status415UnsupportedMediaType, - // }; - // } - // - // return model; - // } + /// + /// Deserialize a single object from a stream + /// + public async Task> DeserializeSingleFromStream( + Stream body, + string? contentType, + DataType dataType + ) + { + using var memoryStream = new MemoryStream(); + await body.CopyToAsync(memoryStream); + if (!memoryStream.TryGetBuffer(out var segment)) + { + throw new InvalidOperationException("Failed to get buffer from memory stream"); + } + + var modelType = GetModelTypeForDataType(dataType); + object model; + if (contentType?.Contains("application/xml") ?? true) // default to xml if no content type is provided + { + model = DeserializeXml(segment, modelType); + } + else if (contentType.Contains("application/json")) + { + model = DeserializeJson(segment, modelType); + } + else + { + return new ProblemDetails() + { + Title = "Unsupported content type", + Detail = $"Content type {contentType} is not supported for deserialization", + Status = StatusCodes.Status415UnsupportedMediaType, + }; + } + + return model; + } /// /// Deserialize utf8 encoded json data to a model of the specified type diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index f7ec34928..102af55a1 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -4,6 +4,7 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; @@ -24,6 +25,7 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataMutator // Services from DI private readonly IDataClient _dataClient; + private readonly IInstanceClient _instanceClient; private readonly IAppMetadata _appMetadata; private readonly ModelSerializationService _modelSerializationService; @@ -46,9 +48,14 @@ internal sealed class CachedInstanceDataAccessor : IInstanceDataMutator ReadOnlyMemory bytes )> _dataElementsToAdd = new(); + // The update functions returns updated data elements. + // We want to make sure that the data elements are updated in the instance object + private readonly ConcurrentBag _savedDataElements = new(); + public CachedInstanceDataAccessor( Instance instance, IDataClient dataClient, + IInstanceClient instanceClient, IAppMetadata appMetadata, ModelSerializationService modelSerializationService ) @@ -63,6 +70,7 @@ ModelSerializationService modelSerializationService _dataClient = dataClient; _appMetadata = appMetadata; _modelSerializationService = modelSerializationService; + _instanceClient = instanceClient; } public Instance Instance { get; } @@ -103,18 +111,19 @@ public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { return Instance.Data.Find(d => d.Id == dataElementIdentifier.Id) ?? throw new InvalidOperationException( - $"Data element with id {dataElementIdentifier.Id} not found in instance" + $"Data element of id {dataElementIdentifier.Id} not found on instance" ); } - private DataType GetDataType(DataElementIdentifier dataElementIdentifier) + /// + public DataType GetDataType(DataElementIdentifier dataElementIdentifier) { var dataElement = GetDataElement(dataElementIdentifier); var appMetadata = _appMetadata.GetApplicationMetadata().Result; var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); if (dataType is null) { - throw new InvalidOperationException($"Data type {dataElement.DataType} not found in instance"); + throw new InvalidOperationException($"Data type {dataElement.DataType} not found in applicationmetadata.json"); } return dataType; @@ -184,7 +193,7 @@ public List GetDataElementChanges(bool initializeAltinnRowId) { DataElementIdentifier dataElementIdentifier = dataElement; object? data = _formDataCache.GetCachedValueOrDefault(dataElementIdentifier); - // Skip data elements that have not been fetched + // Skip data elements that have not been deserialized into a object if (data is null) continue; var dataType = GetDataType(dataElementIdentifier); @@ -217,7 +226,7 @@ public List GetDataElementChanges(bool initializeAltinnRowId) return changes; } - internal async Task UpdateInstanceData() + internal async Task UpdateInstanceData(List changes) { var tasks = new List(); ConcurrentBag createdDataElements = new(); @@ -267,6 +276,13 @@ await _dataClient.DeleteData( await Task.WhenAll(tasks); + //Update DataValues and presentation texts + foreach (var change in changes) + { + await UpdateDataValuesOnInstance(Instance, change.DataElement.DataType, change.CurrentFormData); + await UpdatePresentationTextsOnInstance(Instance, change.DataElement.DataType, change.CurrentFormData); + } + // Remove deleted data elements from instance.Data Instance.Data.RemoveAll(dataElement => _dataElementsToDelete.Any(d => d.Id == dataElement.Id)); @@ -285,17 +301,19 @@ internal async Task SaveChanges(List changes) throw new InvalidOperationException("Changes sent to SaveChanges must have a CurrentBinaryData value"); } - var dataElement = GetDataElement(change.DataElement); - - tasks.Add( - _dataClient.UpdateBinaryData( + async Task UpdateDataDlement() + { + var newDataElement = await _dataClient.UpdateBinaryData( new InstanceIdentifier(Instance), - dataElement.ContentType, - dataElement.Filename, + change.DataElement.ContentType, + change.DataElement.Filename, Guid.Parse(change.DataElement.Id), new MemoryAsStream(change.CurrentBinaryData.Value) - ) - ); + ); + _savedDataElements.Add(newDataElement); + } + + tasks.Add(UpdateDataDlement()); } await Task.WhenAll(tasks); @@ -320,17 +338,6 @@ internal void SetFormData(DataElementIdentifier dataElementIdentifier, object da _formDataCache.Set(dataElementIdentifier, data); } - /// - /// Compatibility function to update both formDataCache and binaryCache as we assume storage has already been updated. - /// - [Obsolete("Should only be used for actions that set UpdatedDataModels on UserActionResult which is deprecated")] - internal void ReplaceFormDataAssumeSavedToStorage(DataElementIdentifier dataElementIdentifier, object newModel) - { - SetFormData(dataElementIdentifier, newModel); - var (data, _) = _modelSerializationService.SerializeToStorage(newModel, GetDataType(dataElementIdentifier)); - _binaryCache.Set(dataElementIdentifier, data); - } - /// /// Simple wrapper around a Dictionary using Lazy to ensure that the valueFactory is only called once /// @@ -399,4 +406,42 @@ internal void VerifyDataElementsUnchanged() ); } } + + private async Task UpdatePresentationTextsOnInstance(Instance instance, string dataType, object serviceModel) + { + var updatedValues = DataHelper.GetUpdatedDataValues( + (await _appMetadata.GetApplicationMetadata()).PresentationFields, + instance.PresentationTexts, + dataType, + serviceModel + ); + + if (updatedValues.Count > 0) + { + await _instanceClient.UpdatePresentationTexts( + int.Parse(instance.Id.Split("/")[0], CultureInfo.InvariantCulture), + Guid.Parse(instance.Id.Split("/")[1]), + new PresentationTexts { Texts = updatedValues } + ); + } + } + + private async Task UpdateDataValuesOnInstance(Instance instance, string dataType, object serviceModel) + { + var updatedValues = DataHelper.GetUpdatedDataValues( + (await _appMetadata.GetApplicationMetadata()).DataFields, + instance.DataValues, + dataType, + serviceModel + ); + + if (updatedValues.Count > 0) + { + await _instanceClient.UpdateDataValues( + int.Parse(instance.Id.Split("/")[0], CultureInfo.InvariantCulture), + Guid.Parse(instance.Id.Split("/")[1]), + new DataValues { Values = updatedValues } + ); + } + } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 63c0ee5f1..7dfa944fb 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -41,12 +41,19 @@ IOptions frontEndSettings private sealed class SingleDataElementAccessor : IInstanceDataAccessor { private readonly DataElement _dataElement; + private readonly ApplicationMetadata _applicationMetadata; private readonly object _data; - public SingleDataElementAccessor(Instance instance, DataElement dataElement, object data) + public SingleDataElementAccessor( + Instance instance, + DataElement dataElement, + ApplicationMetadata applicationMetadata, + object data + ) { Instance = instance; _dataElement = dataElement; + _applicationMetadata = applicationMetadata; _data = data; } @@ -72,13 +79,22 @@ public Task> GetBinaryData(DataElementIdentifier dataElemen public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { - if (dataElementIdentifier != _dataElement) - { - throw new InvalidOperationException( - "Use the new ILayoutEvaluatorStateInitializer interface to support multiple data models and subforms" + return Instance.Data.Find(d => d.Id == dataElementIdentifier.Id) + ?? throw new InvalidOperationException( + $"Data element of id {dataElementIdentifier.Id} not found on instance" ); + } + + public DataType GetDataType(DataElementIdentifier dataElementIdentifier) + { + var dataElement = GetDataElement(dataElementIdentifier); + var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType is null) + { + throw new InvalidOperationException($"Data type {dataElement.DataType} not found in applicationmetadata.json"); } - return _dataElement; + + return dataType; } // Not implemented @@ -125,7 +141,7 @@ public async Task Init( var dataElement = instance.Data.Find(d => d.DataType == layouts.DefaultDataType.Id); Debug.Assert(dataElement is not null); var appMetadata = await _appMetadata.GetApplicationMetadata(); - var dataAccessor = new SingleDataElementAccessor(instance, dataElement, data); + var dataAccessor = new SingleDataElementAccessor(instance, dataElement, appMetadata, data); return new LayoutEvaluatorState(dataAccessor, layouts, _frontEndSettings, appMetadata, gatewayAction); } diff --git a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs index a92e70a98..ee06c5a46 100644 --- a/src/Altinn.App.Core/Internal/Patch/IPatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/IPatchService.cs @@ -1,3 +1,4 @@ +using Altinn.App.Core.Features; using Altinn.App.Core.Models.Result; using Altinn.Platform.Storage.Interface.Models; using Json.Patch; @@ -12,15 +13,20 @@ public interface IPatchService /// /// Applies a patch to a Form Data element /// - /// - /// - /// - /// - /// Task> ApplyPatches( Instance instance, Dictionary patches, string? language, List? ignoredValidators ); + + /// + /// Runs data processors on all the changes. + /// + Task RunDataProcessors( + IInstanceDataMutator dataMutator, + List changes, + string taskId, + string? language + ); } diff --git a/src/Altinn.App.Core/Internal/Patch/PatchService.cs b/src/Altinn.App.Core/Internal/Patch/PatchService.cs index 5dc1d9ecd..84bcfebaa 100644 --- a/src/Altinn.App.Core/Internal/Patch/PatchService.cs +++ b/src/Altinn.App.Core/Internal/Patch/PatchService.cs @@ -5,6 +5,7 @@ using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Result; @@ -22,6 +23,7 @@ internal class PatchService : IPatchService { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; + private readonly IInstanceClient _instanceClient; private readonly ModelSerializationService _modelSerializationService; private readonly IWebHostEnvironment _hostingEnvironment; private readonly Telemetry? _telemetry; @@ -38,6 +40,7 @@ internal class PatchService : IPatchService public PatchService( IAppMetadata appMetadata, IDataClient dataClient, + IInstanceClient instanceClient, IValidationService validationService, IEnumerable dataProcessors, IEnumerable dataWriteProcessors, @@ -48,6 +51,7 @@ public PatchService( { _appMetadata = appMetadata; _dataClient = dataClient; + _instanceClient = instanceClient; _validationService = validationService; _dataProcessors = dataProcessors; _dataWriteProcessors = dataWriteProcessors; @@ -69,6 +73,7 @@ public async Task> ApplyPatches( var dataAccessor = new CachedInstanceDataAccessor( instance, _dataClient, + _instanceClient, _appMetadata, _modelSerializationService ); @@ -139,56 +144,19 @@ public async Task> ApplyPatches( ); } - foreach (var dataProcessor in _dataProcessors) - { - foreach (var change in changesAfterPatch) - { - var dataElementGuid = Guid.Parse(change.DataElement.Id); - using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataProcessor); - try - { - // TODO: Create new dataProcessor interface that takes multiple models at the same time. - await dataProcessor.ProcessDataWrite( - instance, - dataElementGuid, - change.CurrentFormData, - change.PreviousFormData, - language - ); - } - catch (Exception e) - { - processWriteActivity?.Errored(e); - throw; - } - } - } - - foreach (var dataWriteProcessor in _dataWriteProcessors) - { - using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataWriteProcessor); - try - { - await dataWriteProcessor.ProcessDataWrite( - dataAccessor, - instance.Process.CurrentTask.ElementId, - changesAfterPatch, - language - ); - } - catch (Exception e) - { - processWriteActivity?.Errored(e); - throw; - } - } + await RunDataProcessors( + dataAccessor, + changesAfterPatch, + taskId: instance.Process.CurrentTask.ElementId, + language + ); // Get all changes to data elements by comparing the serialized values var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: true); // Start saving changes in parallel with validation Task saveChanges = dataAccessor.SaveChanges(changes); // Update instance data to reflect the changes and save created data elements - await dataAccessor.UpdateInstanceData(); + await dataAccessor.UpdateInstanceData(changes); var validationIssues = await _validationService.ValidateIncrementalFormData( instance, @@ -235,6 +203,53 @@ await dataWriteProcessor.ProcessDataWrite( }; } + public async Task RunDataProcessors( + IInstanceDataMutator dataMutator, + List changes, + string taskId, + string? language + ) + { + foreach (var dataProcessor in _dataProcessors) + { + foreach (var change in changes) + { + var dataElementGuid = Guid.Parse(change.DataElement.Id); + using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataProcessor); + try + { + // TODO: Create new dataProcessor interface that takes multiple models at the same time. + await dataProcessor.ProcessDataWrite( + dataMutator.Instance, + dataElementGuid, + change.CurrentFormData, + change.PreviousFormData, + language + ); + } + catch (Exception e) + { + processWriteActivity?.Errored(e); + throw; + } + } + } + + foreach (var dataWriteProcessor in _dataWriteProcessors) + { + using var processWriteActivity = _telemetry?.StartDataProcessWriteActivity(dataWriteProcessor); + try + { + await dataWriteProcessor.ProcessDataWrite(dataMutator, taskId, changes, language); + } + catch (Exception e) + { + processWriteActivity?.Errored(e); + throw; + } + } + } + private static ServiceResult DeserializeModel(Type type, JsonNode? patchResult) { try diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 588de6a7a..35515a701 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -7,6 +7,7 @@ using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; using Altinn.App.Core.Internal.Process.ProcessTasks; @@ -33,6 +34,7 @@ public class ProcessEngine : IProcessEngine private readonly Telemetry? _telemetry; private readonly IProcessTaskCleaner _processTaskCleaner; private readonly IDataClient _dataClient; + private readonly IInstanceClient _instanceClient; private readonly ModelSerializationService _modelSerialization; private readonly IAppMetadata _appMetadata; @@ -48,6 +50,7 @@ public ProcessEngine( IProcessTaskCleaner processTaskCleaner, UserActionService userActionService, IDataClient dataClient, + IInstanceClient instanceClient, ModelSerializationService modelSerialization, IAppMetadata appMetadata, Telemetry? telemetry = null @@ -61,6 +64,7 @@ public ProcessEngine( _processTaskCleaner = processTaskCleaner; _userActionService = userActionService; _dataClient = dataClient; + _instanceClient = instanceClient; _modelSerialization = modelSerialization; _appMetadata = appMetadata; _telemetry = telemetry; @@ -170,6 +174,7 @@ public async Task Next(ProcessNextRequest request) var cachedDataMutator = new CachedInstanceDataAccessor( instance, _dataClient, + _instanceClient, _appMetadata, _modelSerialization ); @@ -191,7 +196,7 @@ public async Task Next(ProcessNextRequest request) } var changes = cachedDataMutator.GetDataElementChanges(initializeAltinnRowId: false); - await cachedDataMutator.UpdateInstanceData(); + await cachedDataMutator.UpdateInstanceData(changes); await cachedDataMutator.SaveChanges(changes); ProcessStateChange? nextResult = await HandleMoveToNext(instance, request.User, request.Action); diff --git a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs index a0eeabef1..a8fd9a43a 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs @@ -2,6 +2,7 @@ using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; using Altinn.App.Core.Models.Process; @@ -19,6 +20,7 @@ public class ProcessNavigator : IProcessNavigator private readonly ExclusiveGatewayFactory _gatewayFactory; private readonly ILogger _logger; private readonly IDataClient _dataClient; + private IInstanceClient _instanceClient; private readonly IAppMetadata _appMetadata; private readonly ModelSerializationService _modelSerialization; @@ -30,6 +32,7 @@ public ProcessNavigator( ExclusiveGatewayFactory gatewayFactory, ILogger logger, IDataClient dataClient, + IInstanceClient instanceClient, IAppMetadata appMetadata, ModelSerializationService modelSerialization ) @@ -40,6 +43,7 @@ ModelSerializationService modelSerialization _dataClient = dataClient; _appMetadata = appMetadata; _modelSerialization = modelSerialization; + _instanceClient = instanceClient; } /// @@ -113,6 +117,7 @@ private async Task> NextFollowAndFilterGateways( IInstanceDataAccessor dataAccessor = new CachedInstanceDataAccessor( instance, _dataClient, + _instanceClient, _appMetadata, _modelSerialization ); diff --git a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs index d3c4dcffe..edc82c64c 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizer.cs @@ -7,6 +7,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Options; @@ -18,6 +19,7 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer { private readonly IAppMetadata _appMetadata; private readonly IDataClient _dataClient; + private readonly IInstanceClient _intanceClient; private readonly IAppModel _appModel; private readonly ILayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; private readonly IOptions _appSettings; @@ -29,6 +31,7 @@ public class ProcessTaskFinalizer : IProcessTaskFinalizer public ProcessTaskFinalizer( IAppMetadata appMetadata, IDataClient dataClient, + IInstanceClient intanceClient, IAppModel appModel, ModelSerializationService modelSerializer, ILayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer, @@ -39,6 +42,7 @@ IOptions appSettings _dataClient = dataClient; _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; _appSettings = appSettings; + _intanceClient = intanceClient; _appModel = appModel; _modelSerializer = modelSerializer; } @@ -46,7 +50,13 @@ IOptions appSettings /// public async Task Finalize(string taskId, Instance instance) { - var dataAccessor = new CachedInstanceDataAccessor(instance, _dataClient, _appMetadata, _modelSerializer); + var dataAccessor = new CachedInstanceDataAccessor( + instance, + _dataClient, + _intanceClient, + _appMetadata, + _modelSerializer + ); ApplicationMetadata applicationMetadata = await _appMetadata.GetApplicationMetadata(); @@ -65,7 +75,7 @@ var dataType in applicationMetadata.DataTypes.Where(dt => await Task.WhenAll(tasks); var changes = dataAccessor.GetDataElementChanges(initializeAltinnRowId: false); - await dataAccessor.UpdateInstanceData(); + await dataAccessor.UpdateInstanceData(changes); await dataAccessor.SaveChanges(changes); } diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs index 2cb8a2459..bfbd4b4f0 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs @@ -55,6 +55,7 @@ public async Task PutDataElement_TestSinglePartUpdate_ReturnsOk() $"/{org}/{app}/instances/{instanceId}/data/{dataGuid}", updateDataElementContent ); + OutputHelper.WriteLine(await response.Content.ReadAsStringAsync()); response.StatusCode.Should().Be(HttpStatusCode.Created); // Verify stored data diff --git a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs index a6e9ac0fd..8b993b50a 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/LegacyValidationServiceTests/ValidationServiceTests.cs @@ -5,6 +5,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; @@ -75,6 +76,7 @@ public class MyModel private readonly Mock> _loggerMock = new(); private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _instanceClientMock = new(MockBehavior.Strict); private readonly IInstanceDataAccessor _dataAccessor; @@ -114,6 +116,7 @@ public ValidationServiceTests() _dataAccessor = new CachedInstanceDataAccessor( _defaultInstance, _dataClientMock.Object, + _instanceClientMock.Object, _appMetadataMock.Object, _modelSerialization ); @@ -374,6 +377,7 @@ public async Task ValidateFormData_WithMyNameValidator_ReturnsErrorsWhenNameIsKa var dataAccessor = new CachedInstanceDataAccessor( _defaultInstance, _dataClientMock.Object, + _instanceClientMock.Object, _appMetadataMock.Object, _modelSerialization ); @@ -452,6 +456,7 @@ List CreateIssues(string code) var dataAccessor = new CachedInstanceDataAccessor( _defaultInstance, _dataClientMock.Object, + _instanceClientMock.Object, _appMetadataMock.Object, _modelSerialization ); diff --git a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs index 988f9a962..fcefd6f83 100644 --- a/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Patch/PatchServiceTests.cs @@ -6,6 +6,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Patch; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -42,6 +43,7 @@ public sealed class PatchServiceTests : IDisposable // Service mocks private readonly Mock> _vLoggerMock = new(MockBehavior.Loose); private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _instanceClientMock = new(MockBehavior.Strict); private readonly Mock _dataProcessorMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); @@ -102,6 +104,7 @@ public PatchServiceTests() _patchService = new PatchService( _appMetadataMock.Object, _dataClientMock.Object, + _instanceClientMock.Object, validationService, [_dataProcessorMock.Object], [], diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs index e67ab0996..750c64c24 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ExpressionsExclusiveGatewayTests.cs @@ -6,6 +6,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models; @@ -26,6 +27,7 @@ public class ExpressionsExclusiveGatewayTests private readonly Mock _appModel = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); private readonly Mock _dataClient = new(MockBehavior.Strict); + private readonly Mock _instanceClient = new(MockBehavior.Strict); private const string Org = "ttd"; private const string App = "test"; @@ -272,6 +274,7 @@ public async Task FilterAsync_Expression_filters_based_on_datamodel_set_by_gatew var dataAccessor = new CachedInstanceDataAccessor( instance, _dataClient.Object, + _instanceClient.Object, _appMetadata.Object, modelSerializationService ); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index d430ad672..c41ef277c 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.ProcessTasks; @@ -39,6 +40,7 @@ public sealed class ProcessEngineTest : IDisposable private readonly Mock _processEventDispatcherMock = new(); private readonly Mock _processTaskCleanerMock = new(); private readonly Mock _dataClientMock = new(MockBehavior.Strict); + private readonly Mock _instanceClientMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(MockBehavior.Strict); private readonly Mock _appMetadataMock = new(MockBehavior.Strict); @@ -1070,6 +1072,7 @@ private ProcessEngine GetProcessEngine( _processTaskCleanerMock.Object, new UserActionService(userActions ?? []), _dataClientMock.Object, + _instanceClientMock.Object, new ModelSerializationService(_appModelMock.Object, telemetrySink?.Object), _appMetadataMock.Object, telemetrySink?.Object diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs index f9ffa5fec..edebcc5e1 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessNavigatorTests.cs @@ -3,6 +3,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; @@ -19,6 +20,7 @@ namespace Altinn.App.Core.Tests.Internal.Process; public class ProcessNavigatorTests { private readonly Mock _dataClient = new(MockBehavior.Strict); + private readonly Mock _instanceClient = new(MockBehavior.Strict); private readonly Mock _appMetadata = new(MockBehavior.Strict); private readonly Mock _appModel = new(MockBehavior.Strict); @@ -277,6 +279,7 @@ IEnumerable gatewayFilters new ExclusiveGatewayFactory(gatewayFilters), new NullLogger(), _dataClient.Object, + _instanceClient.Object, _appMetadata.Object, new ModelSerializationService(_appModel.Object) ); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs index 3a6b63928..2348aff1f 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessTasks/Common/ProcessTaskFinalizerTests.cs @@ -4,6 +4,7 @@ using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.ProcessTasks; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Enums; @@ -17,6 +18,7 @@ public class ProcessTaskFinalizerTests { private readonly Mock _appMetadataMock = new(); private readonly Mock _dataClientMock = new(); + private readonly Mock _instanceClientMock = new(MockBehavior.Strict); private readonly Mock _appModelMock = new(); private readonly Mock _layoutEvaluatorStateInitializerMock = new(); private readonly IOptions _appSettings = Options.Create(new AppSettings()); @@ -27,6 +29,7 @@ public ProcessTaskFinalizerTests() _processTaskFinalizer = new ProcessTaskFinalizer( _appMetadataMock.Object, _dataClientMock.Object, + _instanceClientMock.Object, _appModelMock.Object, new ModelSerializationService(_appModelMock.Object), _layoutEvaluatorStateInitializerMock.Object, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs index 2e7e7e790..ecf5290f2 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -273,7 +273,7 @@ public async Task Test1() using var serviceProvider = _services.BuildServiceProvider(); var validationService = serviceProvider.GetRequiredService(); - var dataAccessor = new InstanceDataAccessorFake(_instance) + var dataAccessor = new InstanceDataAccessorFake(_instance, _applicationMetadata) { { _instance.Data[0], new MainFormModel("Name", "Address", "Phone", null) }, { _instance.Data[1], new SubFormModel(null, null, null, null, false) }, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs index 7be36abd5..273bfc6b9 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/InstanceDataAccessorFake.cs @@ -90,7 +90,26 @@ public Task> GetBinaryData(DataElementIdentifier dataElemen public DataElement GetDataElement(DataElementIdentifier dataElementIdentifier) { - throw new NotImplementedException(); + return Instance.Data.Find(d => d.Id == dataElementIdentifier.Id) + ?? throw new InvalidOperationException( + $"Data element of id {dataElementIdentifier.Id} not found on instance" + ); + } + + public DataType GetDataType(DataElementIdentifier dataElementIdentifier) + { + if (_applicationMetadata is null) + { + throw new InvalidOperationException("Application metadata not set for InstanceDataAccessorFake"); + } + var dataElement = GetDataElement(dataElementIdentifier); + var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); + if (dataType is null) + { + throw new InvalidOperationException($"Data type {dataElement.DataType} not found in applicationmetadata"); + } + + return dataType; } public void AddFormDataElement(string dataType, object model) From 7ff6782e48da1eb92ec5e36fdaf17a26c7759694 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 8 Oct 2024 13:22:27 +0200 Subject: [PATCH 59/63] Fix formatting and move the .gitignore file protecting test data --- .../Internal/Data/CachedInstanceDataAccessor.cs | 4 +++- .../Internal/Expressions/LayoutEvaluatorStateInitializer.cs | 4 +++- .../Instances/tdd/{contributer-restriction => }/.gitignore | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) rename test/Altinn.App.Api.Tests/Data/Instances/tdd/{contributer-restriction => }/.gitignore (64%) diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index 102af55a1..19e74c93d 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -123,7 +123,9 @@ public DataType GetDataType(DataElementIdentifier dataElementIdentifier) var dataType = appMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); if (dataType is null) { - throw new InvalidOperationException($"Data type {dataElement.DataType} not found in applicationmetadata.json"); + throw new InvalidOperationException( + $"Data type {dataElement.DataType} not found in applicationmetadata.json" + ); } return dataType; diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs index 7dfa944fb..796d4aefb 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorStateInitializer.cs @@ -91,7 +91,9 @@ public DataType GetDataType(DataElementIdentifier dataElementIdentifier) var dataType = _applicationMetadata.DataTypes.Find(d => d.Id == dataElement.DataType); if (dataType is null) { - throw new InvalidOperationException($"Data type {dataElement.DataType} not found in applicationmetadata.json"); + throw new InvalidOperationException( + $"Data type {dataElement.DataType} not found in applicationmetadata.json" + ); } return dataType; diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore b/test/Altinn.App.Api.Tests/Data/Instances/tdd/.gitignore similarity index 64% rename from test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore rename to test/Altinn.App.Api.Tests/Data/Instances/tdd/.gitignore index dcf6ac329..8c777c01b 100644 --- a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/.gitignore +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/.gitignore @@ -1,4 +1,4 @@ # Ignore guid.json files ????????-????-????-????-????????????.json # ignore copied blobs -*/*/blob/????????-????-????-????-???????????? \ No newline at end of file +*/*/*/blob/????????-????-????-????-???????????? From 93e7ef3f6dc7a17c7ce8a40849ad0522df3509d7 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 8 Oct 2024 19:43:56 +0200 Subject: [PATCH 60/63] Fix codeQL warning --- .../Internal/Data/CachedInstanceDataAccessor.cs | 7 +++---- src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs index 19e74c93d..4e598c22e 100644 --- a/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs +++ b/src/Altinn.App.Core/Internal/Data/CachedInstanceDataAccessor.cs @@ -303,7 +303,7 @@ internal async Task SaveChanges(List changes) throw new InvalidOperationException("Changes sent to SaveChanges must have a CurrentBinaryData value"); } - async Task UpdateDataDlement() + async Task UpdateDataElement() { var newDataElement = await _dataClient.UpdateBinaryData( new InstanceIdentifier(Instance), @@ -315,7 +315,7 @@ async Task UpdateDataDlement() _savedDataElements.Add(newDataElement); } - tasks.Add(UpdateDataDlement()); + tasks.Add(UpdateDataElement()); } await Task.WhenAll(tasks); @@ -375,8 +375,7 @@ public void Set(DataElementIdentifier key, T data) { if ( _cache.TryGetValue(identifier.Guid, out var lazyTask) - && lazyTask.IsValueCreated - && lazyTask.Value.IsCompletedSuccessfully + && lazyTask is { IsValueCreated: true, Value.IsCompletedSuccessfully: true } ) { return lazyTask.Value.Result; diff --git a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs index a8fd9a43a..d1548f90f 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessNavigator.cs @@ -20,7 +20,7 @@ public class ProcessNavigator : IProcessNavigator private readonly ExclusiveGatewayFactory _gatewayFactory; private readonly ILogger _logger; private readonly IDataClient _dataClient; - private IInstanceClient _instanceClient; + private readonly IInstanceClient _instanceClient; private readonly IAppMetadata _appMetadata; private readonly ModelSerializationService _modelSerialization; @@ -92,7 +92,7 @@ private async Task> NextFollowAndFilterGateways( var gateway = (ExclusiveGateway)directFlowTarget; List outgoingFlows = _processReader.GetOutgoingSequenceFlows(directFlowTarget); - IProcessExclusiveGateway? gatewayFilter = null; + IProcessExclusiveGateway? gatewayFilter; if (outgoingFlows.Any(a => a.ConditionExpression != null)) { gatewayFilter = _gatewayFactory.GetProcessExclusiveGateway("AltinnExpressionsExclusiveGateway"); From 12978b1a3ca997b8e60c7ec0a40938cb1a80578d Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Tue, 8 Oct 2024 21:27:31 +0200 Subject: [PATCH 61/63] Add test for FormDataValidatorWrapper --- .../Validators/ValidationServiceTests.cs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs index 5a5fb1172..693cfe782 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/ValidationServiceTests.cs @@ -321,6 +321,107 @@ public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFu await Verify(new { telemetry = telemetry.GetSnapshot(), issues = issues }, verifySettings); } + [Fact] + public async Task FormDataValidator_DataTypeNoAppLogic_IsNotCalled() + { + // Form DataValidators are connected to DataType, + // and should only run for instances with that data type + // on the task that has that data type + + var dataElement = new DataElement { Id = Guid.NewGuid().ToString(), DataType = "dataType" }; + + var formDataValidatorNoAppLogicMock = new Mock(MockBehavior.Strict) + { + Name = "FormDataValidatorNoAppLogic" + }; + formDataValidatorNoAppLogicMock + .SetupGet(v => v.DataType) + .Returns("dataTypeNoAppLogic") + .Verifiable(Times.AtLeastOnce); + formDataValidatorNoAppLogicMock + .SetupGet(v => v.ValidationSource) + .Returns("FormDataValidatorNoAppLogic") + .Verifiable(Times.AtLeastOnce); + _services.AddSingleton(formDataValidatorNoAppLogicMock.Object); + _appMetadata.DataTypes.Add(new DataType { Id = "dataTypeNoAppLogic", TaskId = TaskId }); + + var formDataValidatorWrongTaskMock = new Mock(MockBehavior.Strict) + { + Name = "FormDataValidatorWrongTask" + }; + formDataValidatorWrongTaskMock + .SetupGet(v => v.DataType) + .Returns("dataTypeWrongTask") + .Verifiable(Times.AtLeastOnce); + formDataValidatorWrongTaskMock + .SetupGet(v => v.ValidationSource) + .Returns("FormDataValidatorWrongTask") + .Verifiable(Times.AtLeastOnce); + _services.AddSingleton(formDataValidatorWrongTaskMock.Object); + _appMetadata.DataTypes.Add( + new DataType + { + Id = "dataTypeWrongTask", + TaskId = "wrongTask", + AppLogic = new() { ClassRef = "System.String" } + } + ); + + var formDataValidatorMock = new Mock(MockBehavior.Strict) { Name = "FormDataValidator" }; + formDataValidatorMock.SetupGet(v => v.DataType).Returns("dataType").Verifiable(Times.AtLeastOnce); + formDataValidatorMock + .SetupGet(v => v.ValidationSource) + .Returns("FormDataValidator") + .Verifiable(Times.AtLeastOnce); + formDataValidatorMock + .Setup(v => v.ValidateFormData(_instance, dataElement, "valueToValidate", null)) + .ReturnsAsync( + new List() + { + new ValidationIssue() + { + Severity = ValidationIssueSeverity.Error, + Description = "Test error", + Code = "TestCode543" + } + } + ); + _services.AddSingleton(formDataValidatorMock.Object); + _appMetadata.DataTypes.Add( + new DataType + { + Id = "dataType", + AppLogic = new() { ClassRef = "System.String" }, + TaskId = TaskId, + } + ); + + // Ensure that we have data elements for all types + _instanceDataAccessor.Add(dataElement, "valueToValidate"); + _instanceDataAccessor.Add( + new DataElement() { Id = Guid.NewGuid().ToString(), DataType = "dataTypeNoAppLogic" }, + "valueToValidate" + ); + _instanceDataAccessor.Add( + new DataElement() { Id = Guid.NewGuid().ToString(), DataType = "dataTypeWrongTask" }, + "valueToValidate" + ); + + var validationService = _serviceProvider.Value.GetRequiredService(); + var issues = await validationService.ValidateInstanceAtTask( + _instance, + _instanceDataAccessor, + "Task_1", + null, + null, + null + ); + issues.Should().ContainSingle(i => i.Code == "TestCode543"); + + formDataValidatorNoAppLogicMock.Verify(); + formDataValidatorMock.Verify(); + } + [Fact] public async Task GenericFormDataValidator_serviceModelIsString_CallsValidatorFunctionForIncremental() { From 02aeef3bc0d6eaa39134e00c6ed972b414c28dbc Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Thu, 10 Oct 2024 16:22:05 +0200 Subject: [PATCH 62/63] Update src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs --- .../Helpers/Serialization/ModelSerializationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs index 5fa1b5156..6e39a6417 100644 --- a/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs +++ b/src/Altinn.App.Core/Helpers/Serialization/ModelSerializationService.cs @@ -172,7 +172,7 @@ public object DeserializeXml(ReadOnlySpan data, Type modelType) string streamContent = Encoding.UTF8.GetString(data.RemoveBom()); if (string.IsNullOrWhiteSpace(streamContent)) { - throw new Exception("No XML content read from stream"); + throw new ArgumentException("No XML content read from stream"); } try { From aee32775a664c5117c2fd9f9bb5639f8fcea1de9 Mon Sep 17 00:00:00 2001 From: Ivar Nesje Date: Fri, 11 Oct 2024 08:32:15 +0200 Subject: [PATCH 63/63] Add test for legacy LayoutEvaluatorInitializer --- .../Implementation/AppResourcesSI.cs | 16 ++- .../Interface/IAppResources.cs | 1 + .../DataController_LayoutEvaluatorTests.cs | 109 ++++++++++++++++++ .../Controllers/DataController_PutTests.cs | 82 +++++++++---- ...4-5bc1-4888-8e06-c634753c5144.pretest.json | 29 +++++ ...e04c65-aa70-40ec-84df-087cc2583402.pretest | 1 + ...5-aa70-40ec-84df-087cc2583402.pretest.json | 24 ++++ 7 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 test/Altinn.App.Api.Tests/Controllers/DataController_LayoutEvaluatorTests.cs create mode 100644 test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144.pretest.json create mode 100644 test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/blob/f3e04c65-aa70-40ec-84df-087cc2583402.pretest create mode 100644 test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/f3e04c65-aa70-40ec-84df-087cc2583402.pretest.json diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index 3d768f894..b0ce67d02 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -310,10 +310,22 @@ public string GetLayoutsForSet(string layoutSetId) } /// - [Obsolete("Use GetLayoutModelForTask instead", true)] + [Obsolete("Use GetLayoutModelForTask instead")] public LayoutModel GetLayoutModel(string? layoutSetId = null) { - throw new NotImplementedException(); + var sets = GetLayoutSet(); + if (sets is null) + { + throw new InvalidOperationException("No layout set found"); + } + var set = sets.Sets.First(s => s.Id == layoutSetId); + + if (set.Tasks != null) + { + return GetLayoutModelForTask(set.Tasks.First()) + ?? throw new InvalidOperationException("No layout model found"); + } + throw new InvalidOperationException("No tasks found in layout set"); } /// diff --git a/src/Altinn.App.Core/Interface/IAppResources.cs b/src/Altinn.App.Core/Interface/IAppResources.cs index 463b90505..cbdccebc7 100644 --- a/src/Altinn.App.Core/Interface/IAppResources.cs +++ b/src/Altinn.App.Core/Interface/IAppResources.cs @@ -154,6 +154,7 @@ public interface IAppResources /// /// Gets the full layout model for the optional set /// + [Obsolete("Use GetLayoutModelForTask instead")] LayoutModel GetLayoutModel(string? layoutSetId = null); /// diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_LayoutEvaluatorTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_LayoutEvaluatorTests.cs new file mode 100644 index 000000000..d1e576181 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_LayoutEvaluatorTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Text.Json; +using Altinn.App.Api.Tests.Data; +using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; +using Altinn.App.Core.Features; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Controllers; + +public class DataController_LayoutEvaluatorTests : ApiTestBase, IClassFixture> +{ + public DataController_LayoutEvaluatorTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) + : base(factory, outputHelper) { } + + private class DataProcessor : IDataProcessor + { + private readonly LayoutEvaluatorStateInitializer _layoutEvaluatorStateInitializer; + + public DataProcessor(LayoutEvaluatorStateInitializer layoutEvaluatorStateInitializer) + { + _layoutEvaluatorStateInitializer = layoutEvaluatorStateInitializer; + } + + public Task ProcessDataRead(Instance instance, Guid? dataId, object data, string? language) + { + return Task.FromException(new NotImplementedException()); + } + + public async Task ProcessDataWrite( + Instance instance, + Guid? dataId, + object data, + object? previousData, + string? language + ) + { + var layoutSetId = "default"; + var layoutEvaluatorState = await _layoutEvaluatorStateInitializer.Init(instance, data, layoutSetId); + var hidden = await LayoutEvaluator.GetHiddenFieldsForRemoval(layoutEvaluatorState); + if (dataId.HasValue) + { + var id = (DataElementIdentifier)dataId; + hidden + .Should() + .BeEquivalentTo([new DataReference() { DataElementIdentifier = id, Field = "melding.hidden" }]); + if (data is Skjema { Melding: { } melding }) + { + melding.Toggle = !melding.Toggle; + melding.Random = dataId.ToString(); + } + } + } + } + + [Fact] + public async Task PutDataElement_LegacyLayoutEvaluatorState_ReturnsOk() + { + OverrideServicesForThisTest = (services) => + { + services.AddSingleton(); + }; + // Setup test data + string org = "tdd"; + string app = "contributer-restriction"; + int instanceOwnerPartyId = 500600; + Guid instanceGuid = Guid.Parse("cff1cb24-5bc1-4888-8e06-c634753c5144"); + Guid dataGuid = Guid.Parse("f3e04c65-aa70-40ec-84df-087cc2583402"); + HttpClient client = GetRootedClient(org, app, 1337, instanceOwnerPartyId); + + TestData.DeleteInstanceAndData(org, app, instanceOwnerPartyId, instanceGuid); + TestData.PrepareInstance(org, app, instanceOwnerPartyId, instanceGuid); + + // Update data element + using var updateDataElementContent = new StringContent( + """{"melding":{"name": "Ola Nielsen"}}""", + System.Text.Encoding.UTF8, + "application/json" + ); + var response = await client.PutAsync( + $"/{org}/{app}/instances/{instanceOwnerPartyId}/{instanceGuid}/data/{dataGuid}", + updateDataElementContent + ); + var changes = await VerifyStatusAndDeserialize(response, HttpStatusCode.OK); + changes.ChangedFields.Should().HaveCount(2); + changes + .ChangedFields.Should() + .ContainKey("melding.toggle") + .WhoseValue.Should() + .BeOfType() + .Which.GetBoolean() + .Should() + .BeTrue(); + changes + .ChangedFields.Should() + .ContainKey("melding.random") + .WhoseValue.Should() + .BeOfType() + .Which.GetString() + .Should() + .Be(dataGuid.ToString()); + } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs index bfbd4b4f0..cd0f0ccde 100644 --- a/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/DataController_PutTests.cs @@ -1,8 +1,11 @@ using System.Net; using System.Net.Http.Headers; +using System.Text.Json; +using Altinn.App.Api.Tests.Data; using Altinn.App.Api.Tests.Data.apps.tdd.contributer_restriction.models; using Altinn.App.Api.Tests.Utils; using Altinn.App.Core.Features; +using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; @@ -14,7 +17,8 @@ namespace Altinn.App.Api.Tests.Controllers; public class DataController_PutTests : ApiTestBase, IClassFixture> { - private readonly Mock _dataProcessor = new(); + private readonly Mock _dataProcessor = new(MockBehavior.Strict); + private readonly Mock _dataWriteProcessor = new(MockBehavior.Strict); public DataController_PutTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) : base(factory, outputHelper) @@ -22,6 +26,7 @@ public DataController_PutTests(WebApplicationFactory factory, ITestOutp OverrideServicesForAllTests = (services) => { services.AddSingleton(_dataProcessor.Object); + services.AddSingleton(_dataWriteProcessor.Object); }; } @@ -36,6 +41,36 @@ public async Task PutDataElement_TestSinglePartUpdate_ReturnsOk() string token = PrincipalUtil.GetToken(1337, null, org: "abc"); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + _dataProcessor + .Setup(p => + p.ProcessDataWrite( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(1)); + _dataProcessor + .Setup(p => + p.ProcessDataRead(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(1)); + _dataWriteProcessor + .Setup(p => + p.ProcessDataWrite( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(1)); + // Create instance var createResponse = await client.PostAsync( $"{org}/{app}/instances/?instanceOwnerPartyId={instanceOwnerPartyId}", @@ -115,7 +150,26 @@ public async Task PutDataElement_TestMultiPartUpdateWithCustomDataProcessor_Retu return Task.CompletedTask; } - ); + ) + .Verifiable(Times.Exactly(1)); + + _dataProcessor + .Setup(p => + p.ProcessDataRead(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(2)); + _dataWriteProcessor + .Setup(p => + p.ProcessDataWrite( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny() + ) + ) + .Returns(Task.CompletedTask) + .Verifiable(Times.Exactly(1)); // Run previous test with different setup // Setup test data @@ -167,27 +221,7 @@ public async Task PutDataElement_TestMultiPartUpdateWithCustomDataProcessor_Retu readDataElementResponseParsed.Melding!.Name.Should().Be("Ola Olsen"); readDataElementResponseParsed.Melding.Toggle.Should().BeTrue(); - _dataProcessor.Verify( - p => - p.ProcessDataRead( - It.IsAny(), - It.Is(dataId => dataId == Guid.Parse(dataGuid)), - It.IsAny(), - null - ), - Times.Exactly(2) - ); - _dataProcessor.Verify( - p => - p.ProcessDataWrite( - It.IsAny(), - It.Is(dataId => dataId == Guid.Parse(dataGuid)), - It.IsAny(), - It.IsAny(), - null - ), - Times.Exactly(1) - ); // TODO: Shouldn't this be 2 because of the first write? - _dataProcessor.VerifyNoOtherCalls(); + _dataProcessor.Verify(); + _dataWriteProcessor.Verify(); } } diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144.pretest.json b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144.pretest.json new file mode 100644 index 000000000..d1458550a --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144.pretest.json @@ -0,0 +1,29 @@ +{ + "id": "500600/cff1cb24-5bc1-4888-8e06-c634753c5144", + "instanceOwner": { + "partyId": "500600", + "personNumber": "01039012345" + }, + "appId": "tdd/contributer-restriction", + "org": "tdd", + "process": { + "started": "2024-10-10T19:54:34.291946Z", + "startEvent": "StartEvent_1", + "currentTask": { + "flow": 2, + "started": "2024-10-10T19:54:34.29671Z", + "elementId": "Task_1", + "name": "Utfylling", + "altinnTaskType": "data", + "flowType": "CompleteCurrentMoveToNext" + } + }, + "status": { + "isArchived": false, + "isSoftDeleted": false, + "isHardDeleted": false, + "readStatus": "Read" + }, + "data": [], + "lastChanged": "2024-10-10T19:54:34.368809Z" +} diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/blob/f3e04c65-aa70-40ec-84df-087cc2583402.pretest b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/blob/f3e04c65-aa70-40ec-84df-087cc2583402.pretest new file mode 100644 index 000000000..f5b9c9a94 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/blob/f3e04c65-aa70-40ec-84df-087cc2583402.pretest @@ -0,0 +1 @@ +Ola Olsentrue \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/f3e04c65-aa70-40ec-84df-087cc2583402.pretest.json b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/f3e04c65-aa70-40ec-84df-087cc2583402.pretest.json new file mode 100644 index 000000000..fced89236 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Data/Instances/tdd/contributer-restriction/500600/cff1cb24-5bc1-4888-8e06-c634753c5144/f3e04c65-aa70-40ec-84df-087cc2583402.pretest.json @@ -0,0 +1,24 @@ +{ + "id": "f3e04c65-aa70-40ec-84df-087cc2583402", + "instanceGuid": "cff1cb24-5bc1-4888-8e06-c634753c5144", + "dataType": "default", + "filename": null, + "contentType": "application/xml", + "blobStoragePath": null, + "selfLinks": null, + "size": 216, + "contentHash": null, + "locked": false, + "refs": null, + "isRead": true, + "tags": [], + "userDefinedMetadata": null, + "metadata": null, + "deleteStatus": null, + "fileScanResult": "NotApplicable", + "references": null, + "created": null, + "createdBy": null, + "lastChanged": null, + "lastChangedBy": null +}