Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New PATCH endpoint #384

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 138 additions & 16 deletions src/Altinn.App.Api/Controllers/DataController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

using System.Net;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Nodes;
using Altinn.App.Api.Helpers.RequestHandling;
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.Features;
Expand All @@ -20,6 +23,7 @@
using Altinn.App.Core.Models;
using Altinn.App.Core.Models.Validation;
using Altinn.Platform.Storage.Interface.Models;
using Json.Patch;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
Expand Down Expand Up @@ -122,7 +126,7 @@
{
Application application = await _appMetadata.GetApplicationMetadata();

DataType? dataTypeFromMetadata = application.DataTypes.FirstOrDefault(e => e.Id.Equals(dataType, StringComparison.InvariantCultureIgnoreCase));
DataType? dataTypeFromMetadata = application.DataTypes.First(e => e.Id.Equals(dataType, StringComparison.InvariantCultureIgnoreCase));

if (dataTypeFromMetadata == null)
{
Expand Down Expand Up @@ -256,7 +260,7 @@
return NotFound($"Did not find instance {instance}");
}

DataElement? dataElement = instance.Data.FirstOrDefault(m => m.Id.Equals(dataGuid.ToString()));
DataElement? dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString()));

if (dataElement == null)
{
Expand Down Expand Up @@ -317,7 +321,7 @@
return Conflict($"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}");
}

DataElement? dataElement = instance.Data.FirstOrDefault(m => m.Id.Equals(dataGuid.ToString()));
DataElement? dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString()));

if (dataElement == null)
{
Expand All @@ -338,7 +342,7 @@
return await PutFormData(org, app, instance, dataGuid, dataType);
}

DataType? dataTypeFromMetadata = (await _appMetadata.GetApplicationMetadata()).DataTypes.FirstOrDefault(e => e.Id.Equals(dataType, StringComparison.InvariantCultureIgnoreCase));
DataType? dataTypeFromMetadata = (await _appMetadata.GetApplicationMetadata()).DataTypes.First(e => e.Id.Equals(dataType, StringComparison.InvariantCultureIgnoreCase));
(bool validationRestrictionSuccess, List<ValidationIssue> errors) = DataRestrictionValidation.CompliesWithDataRestrictions(Request, dataTypeFromMetadata);
if (!validationRestrictionSuccess)
{
Expand All @@ -353,6 +357,121 @@
}
}

/// <summary>
/// Updates an existing form data element with a patch of changes.
/// </summary>
/// <param name="org">unique identfier of the organisation responsible for the app</param>
/// <param name="app">application identifier which is unique within an organisation</param>
/// <param name="instanceOwnerPartyId">unique id of the party that is the owner of the instance</param>
/// <param name="instanceGuid">unique id to identify the instance</param>
/// <param name="dataGuid">unique id to identify the data element to update</param>
/// <param name="dataPatchRequest">Container object for the <see cref="JsonPatch" /> and list of ignored validators</param>
/// <returns>A response object with the new full model and validation issues from all the groups that run</returns>
[Authorize(Policy = AuthzConstants.POLICY_INSTANCE_WRITE)]
[HttpPatch("{dataGuid:guid}")]
public async Task<ActionResult<DataPatchResponse>> PatchFormData(
[FromRoute] string org,
[FromRoute] string app,
[FromRoute] int instanceOwnerPartyId,
[FromRoute] Guid instanceGuid,
[FromRoute] Guid dataGuid,
[FromBody] DataPatchRequest dataPatchRequest)
{
try
{
var instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid);

if (!InstanceIsActive(instance))
{
return Conflict(
$"Cannot update data element of archived or deleted instance {instanceOwnerPartyId}/{instanceGuid}");
}

var dataElement = instance.Data.First(m => m.Id.Equals(dataGuid.ToString()));

if (dataElement == null)
{
return NotFound("Did not find data element");
}

var dataType = dataElement.DataType;

var appLogic = await RequiresAppLogic(dataType);

if (appLogic != true)
{
_logger.LogError(
"Could not determine if {dataType} requires app logic for application {org}/{app}",
dataType,
Dismissed Show dismissed Hide dismissed
org,
Dismissed Show dismissed Hide dismissed
app);
Dismissed Show dismissed Hide dismissed
return BadRequest($"Could not determine if data type {dataType} requires application logic.");
}

var modelType = _appModel.GetModelType(_appResourcesService.GetClassRefForLogicDataType(dataType));

var oldModel =
await _dataClient.GetFormData(instanceGuid, modelType, org, app, instanceOwnerPartyId, dataGuid);

var response =
await PatchFormDataImplementation(dataGuid, dataPatchRequest, oldModel, instance, _dataProcessors);

await UpdatePresentationTextsOnInstance(instance, dataType, response.NewDataModel);
await UpdateDataValuesOnInstance(instance, dataType, response.NewDataModel);

// Save Formdata to database
await _dataClient.UpdateData(
response.NewDataModel,
instanceGuid,
modelType,
org,
app,
instanceOwnerPartyId,
dataGuid);

return Ok(response);
}
catch (PlatformHttpException e)
{
return HandlePlatformHttpException(e,$"Unable to update data element {dataGuid} for instance {instanceOwnerPartyId}/{instanceGuid}");
}
}

/// <summary>
/// Part of <see cref="PatchFormData" /> that is separated out for testing purposes.
/// </summary>
/// <param name="dataGuid">unique id to identify the data element to update</param>
/// <param name="dataPatchRequest">Container object for the <see cref="JsonPatch" /> and list of ignored validators</param>
/// <param name="oldModel">The old state of the form data</param>
/// <param name="instance">The instance</param>
/// <param name="dataProcessors">The data processors to run</param>
/// <returns>DataPatchResponse after this patch operation</returns>
public static async Task<DataPatchResponse> PatchFormDataImplementation(Guid dataGuid, DataPatchRequest dataPatchRequest, object oldModel, Instance instance, IEnumerable<IDataProcessor> dataProcessors)
{
var oldModelNode = JsonSerializer.SerializeToNode(oldModel, oldModel.GetType());
var patchResult = dataPatchRequest.Patch.Apply(oldModelNode);
if (!patchResult.IsSuccess)
{
throw new Exception(patchResult.Error); // TODO: Let DataPatchResponse have an error state
}

var model = patchResult.Result.Deserialize(oldModel.GetType())!;

// Run processDataWrite
foreach (var dataProcessor in dataProcessors)
{
await dataProcessor.ProcessDataWrite(instance, dataGuid, model); // TODO: add old model to interface
}

var validationIssues = new Dictionary<string, List<ValidationIssue>>(); // TODO: Run validation
var response = new DataPatchResponse
{
NewDataModel = model,
ValidationIssues = validationIssues
};
return response;
}

/// <summary>
/// Delete a data element.
/// </summary>
Expand Down Expand Up @@ -466,14 +585,12 @@
else
{
ModelDeserializer deserializer = new ModelDeserializer(_logger, _appModel.GetModelType(classRef));
ModelDeserializerResult deserializerResult = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);
appModel = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);

if (deserializerResult.HasError)
if (!string.IsNullOrEmpty(deserializer.Error) || appModel is null)
{
return BadRequest(deserializerResult.Error);
return BadRequest(deserializer.Error);
}

appModel = deserializerResult.Model;
}

// runs prefill from repo configuration if config exists
Expand Down Expand Up @@ -622,21 +739,26 @@
Guid instanceGuid = Guid.Parse(instance.Id.Split("/")[1]);

ModelDeserializer deserializer = new ModelDeserializer(_logger, _appModel.GetModelType(classRef));
ModelDeserializerResult deserializerResult = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);
object? serviceModel = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);

if (!string.IsNullOrEmpty(deserializer.Error))
{
return BadRequest(deserializer.Error);
}

if (deserializerResult.HasError)
if (serviceModel == null)
{
return BadRequest(deserializerResult.Error);
return BadRequest("No data found in content");
}

Dictionary<string, object?>? changedFields = await JsonHelper.ProcessDataWriteWithDiff(instance, dataGuid, deserializerResult.Model, _dataProcessors, deserializerResult.ReportedChanges, _logger);
Dictionary<string, object?>? changedFields = await JsonHelper.ProcessDataWriteWithDiff(instance, dataGuid, serviceModel, _dataProcessors, _logger);

await UpdatePresentationTextsOnInstance(instance, dataType, deserializerResult.Model);
await UpdateDataValuesOnInstance(instance, dataType, deserializerResult.Model);
await UpdatePresentationTextsOnInstance(instance, dataType, serviceModel);
await UpdateDataValuesOnInstance(instance, dataType, serviceModel);

// Save Formdata to database
DataElement updatedDataElement = await _dataClient.UpdateData(
deserializerResult.Model,
serviceModel,
instanceGuid,
_appModel.GetModelType(classRef),
org,
Expand Down
8 changes: 3 additions & 5 deletions src/Altinn.App.Api/Controllers/InstancesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -971,15 +971,13 @@ private async Task StorePrefillParts(Instance instance, ApplicationMetadata appI
}

ModelDeserializer deserializer = new ModelDeserializer(_logger, type);
ModelDeserializerResult deserializerResult = await deserializer.DeserializeAsync(part.Stream, part.ContentType);
object? data = await deserializer.DeserializeAsync(part.Stream, part.ContentType);

if (deserializerResult.HasError)
if (!string.IsNullOrEmpty(deserializer.Error) || data is null)
{
throw new InvalidOperationException(deserializerResult.Error);
throw new InvalidOperationException(deserializer.Error);
}

object data = deserializerResult.Model;

await _prefillService.PrefillDataModel(instance.InstanceOwner.PartyId, part.Name!, data);

await _instantiationProcessor.DataCreation(instance, data, null);
Expand Down
23 changes: 10 additions & 13 deletions src/Altinn.App.Api/Controllers/StatelessDataController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ public async Task<ActionResult> Get(
await _prefillService.PrefillDataModel(owner.PartyId, dataType, appModel);

Instance virtualInstance = new Instance() { InstanceOwner = owner };
await ProcessAllDataWrite(virtualInstance, appModel);
await ProcessAllDataRead(virtualInstance, appModel);

return Ok(appModel);
}

private async Task ProcessAllDataWrite(Instance virtualInstance, object appModel)
private async Task ProcessAllDataRead(Instance virtualInstance, object appModel)
{
foreach (var dataProcessor in _dataProcessors)
{
Expand Down Expand Up @@ -161,7 +161,7 @@ public async Task<ActionResult> GetAnonymous([FromQuery] string dataType)

object appModel = _appModel.Create(classRef);
var virtualInstance = new Instance();
await ProcessAllDataWrite(virtualInstance, appModel);
await ProcessAllDataRead(virtualInstance, appModel);

return Ok(appModel);
}
Expand Down Expand Up @@ -213,15 +213,13 @@ public async Task<ActionResult> Post(
}

ModelDeserializer deserializer = new ModelDeserializer(_logger, _appModel.GetModelType(classRef));
ModelDeserializerResult deserializerResult = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);
object? appModel = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);

if (deserializerResult.HasError)
if (!string.IsNullOrEmpty(deserializer.Error) || appModel is null)
{
return BadRequest(deserializerResult.Error);
return BadRequest(deserializer.Error);
}

object appModel = deserializerResult.Model;

// runs prefill from repo configuration if config exists
await _prefillService.PrefillDataModel(owner.PartyId, dataType, appModel);

Expand Down Expand Up @@ -262,22 +260,21 @@ public async Task<ActionResult> PostAnonymous([FromQuery] string dataType)
}

ModelDeserializer deserializer = new ModelDeserializer(_logger, _appModel.GetModelType(classRef));
ModelDeserializerResult deserializerResult = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);
object? appModel = await deserializer.DeserializeAsync(Request.Body, Request.ContentType);

if (deserializerResult.HasError)
if (!string.IsNullOrEmpty(deserializer.Error) || appModel is null)
{
return BadRequest(deserializerResult.Error);
return BadRequest(deserializer.Error);
}

Instance virtualInstance = new Instance();
var appModel = deserializerResult.Model;
foreach (var dataProcessor in _dataProcessors)
{
_logger.LogInformation("ProcessDataRead for {modelType} using {dataProcesor}", appModel.GetType().Name, dataProcessor.GetType().Name);
await dataProcessor.ProcessDataRead(virtualInstance, null, appModel);
}

return Ok(deserializerResult.Model);
return Ok(appModel);
}

private async Task<InstanceOwner?> GetInstanceOwner(string? partyFromHeader)
Expand Down
25 changes: 25 additions & 0 deletions src/Altinn.App.Api/Models/DataPatchRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#nullable enable
using System.Text.Json.Serialization;
using Altinn.App.Api.Controllers;
using Json.Patch;

namespace Altinn.App.Api.Models;

/// <summary>
/// Represents the request to patch data on the <see cref="DataController"/>.
/// </summary>
public class DataPatchRequest
{
/// <summary>
/// The Patch operation to perform.
/// </summary>
[JsonPropertyName("patch")]
public required JsonPatch Patch { get; init; }

/// <summary>
/// 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
/// </summary>
[JsonPropertyName("ignoredValidators")]
public required List<string>? IgnoredValidators { get; init; }
}
20 changes: 20 additions & 0 deletions src/Altinn.App.Api/Models/DataPatchResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Altinn.App.Api.Controllers;
using Altinn.App.Core.Models.Validation;

namespace Altinn.App.Api.Models;

/// <summary>
/// Represents the response from a data patch operation on the <see cref="DataController"/>.
/// </summary>
public class DataPatchResponse
{
/// <summary>
/// The validation issues that were found during the patch operation.
/// </summary>
public required Dictionary<string, List<ValidationIssue>> ValidationIssues { get; init; }

/// <summary>
/// The current data model after the patch operation.
/// </summary>
public required object NewDataModel { get; init; }
}
1 change: 1 addition & 0 deletions src/Altinn.App.Core/Altinn.App.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="Altinn.Platform.Models" Version="1.2.0" />
<PackageReference Include="Altinn.Platform.Storage.Interface" Version="3.24.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="JsonPatch.Net" Version="2.1.0" />
<PackageReference Include="JWTCookieAuthentication" Version="3.0.1" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="3.0.0" />
Expand Down
3 changes: 1 addition & 2 deletions src/Altinn.App.Core/Features/IDataProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,5 @@ public interface IDataProcessor
/// <param name="instance">Instance that data belongs to</param>
/// <param name="dataId">Data id for the data (nullable if stateless)</param>
/// <param name="data">The data to perform calculations on</param>
/// <param name="changedFields">optional dictionary of field keys and previous values (untrusted from frontend)</param>
public Task ProcessDataWrite(Instance instance, Guid? dataId, object data, Dictionary<string, string?>? changedFields);
public Task ProcessDataWrite(Instance instance, Guid? dataId, object data);
}
Loading
Loading