Skip to content

Commit

Permalink
merge main into feature
Browse files Browse the repository at this point in the history
  • Loading branch information
framitdavid committed Nov 21, 2024
2 parents 9c92935 + 498673f commit 6d74368
Show file tree
Hide file tree
Showing 39 changed files with 561 additions and 744 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace Altinn.Studio.Designer.Exceptions.Options;

/// <summary>
/// Indicates that an error occurred during json serialization of options.
/// </summary>
[Serializable]
public class InvalidOptionsFormatException : Exception
{
/// <inheritdoc/>
public InvalidOptionsFormatException()
{
}

/// <inheritdoc/>
public InvalidOptionsFormatException(string message) : base(message)
{
}

/// <inheritdoc/>
public InvalidOptionsFormatException(string message, Exception innerException) : base(message, innerException)
{
}
}
6 changes: 6 additions & 0 deletions backend/src/Designer/Filters/Options/OptionsErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Altinn.Studio.Designer.Filters.Options;

public class OptionsErrorCodes
{
public const string InvalidOptionsFormat = nameof(InvalidOptionsFormat);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Net;
using Altinn.Studio.Designer.Exceptions.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Altinn.Studio.Designer.Filters.Options;

public class OptionsExceptionFilterAttribute : ExceptionFilterAttribute
{
public override void OnException(ExceptionContext context)
{
base.OnException(context);

if (context.ActionDescriptor is not ControllerActionDescriptor)
{
return;
}

if (context.Exception is InvalidOptionsFormatException)
{
context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, OptionsErrorCodes.InvalidOptionsFormat, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest };
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ namespace Altinn.Studio.Designer.Filters;
public static class ProblemDetailsExtensionsCodes
{
public const string ErrorCode = "errorCode";

public const string Detail = "detail";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;

namespace Altinn.Studio.Designer.Helpers.JsonConverterHelpers;

public class NotNullableAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value == null)
{
return new ValidationResult("The field is required.");
}
return ValidationResult.Success;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using Altinn.Studio.Designer.Exceptions.Options;

namespace Altinn.Studio.Designer.Helpers.JsonConverterHelpers;

public class OptionConverter : JsonConverter<object>
public class OptionValueConverter : JsonConverter<object>
{
public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
Expand All @@ -14,7 +15,7 @@ public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS
JsonTokenType.Number when reader.TryGetDouble(out double d) => d,
JsonTokenType.True => true,
JsonTokenType.False => false,
_ => throw new JsonException($"Unsupported JSON token for Option.Value: {reader.TokenType}")
_ => throw new InvalidOptionsFormatException($"Unsupported JSON token for Value field, {typeToConvert}: {reader.TokenType}.")
};
}

Expand All @@ -32,7 +33,7 @@ public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOp
writer.WriteBooleanValue(b);
break;
default:
throw new JsonException("Unsupported type for Option.Value.");
throw new InvalidOptionsFormatException($"{value} is an unsupported type for Value field. Accepted types are string, double and bool.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -425,20 +425,9 @@ public string[] GetLayoutSetNames()
public void ChangeLayoutSetFolderName(string oldLayoutSetName, string newLayoutSetName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (DirectoryExistsByRelativePath(GetPathToLayoutSet(newLayoutSetName)))
{
throw new NonUniqueLayoutSetIdException("Suggested new layout set name already exist");
}
string destAbsolutePath = GetAbsoluteFileOrDirectoryPathSanitized(GetPathToLayoutSet(newLayoutSetName, true));

string sourceRelativePath = GetPathToLayoutSet(oldLayoutSetName, true);
if (!DirectoryExistsByRelativePath(sourceRelativePath))
{
throw new NotFoundException("Layout set you are trying to change doesn't exist");
}

string sourceAbsolutePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativePath);
Directory.Move(sourceAbsolutePath, destAbsolutePath);
string currentDirectoryPath = GetPathToLayoutSet(oldLayoutSetName, true);
string newDirectoryPath = GetPathToLayoutSet(newLayoutSetName, true);
MoveDirectoryByRelativePath(currentDirectoryPath, newDirectoryPath);
}

/// <summary>
Expand Down Expand Up @@ -575,15 +564,7 @@ public void UpdateFormLayoutName(string layoutSetName, string layoutFileName, st
{
string currentFilePath = GetPathToLayoutFile(layoutSetName, layoutFileName);
string newFilePath = GetPathToLayoutFile(layoutSetName, newFileName);
if (!FileExistsByRelativePath(currentFilePath))
{
throw new FileNotFoundException("Layout does not exist.");
}
if (FileExistsByRelativePath(newFilePath))
{
throw new ArgumentException("New layout name must be unique.");
}
File.Move(GetAbsoluteFileOrDirectoryPathSanitized(currentFilePath), GetAbsoluteFileOrDirectoryPathSanitized(newFilePath));
MoveFileByRelativePath(currentFilePath, newFilePath, newFileName);
}

public async Task<LayoutSets> GetLayoutSetsFile(CancellationToken cancellationToken = default)
Expand Down
45 changes: 36 additions & 9 deletions backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,19 +254,46 @@ public void DeleteFileByAbsolutePath(string absoluteFilePath)
/// <param name="destinationFileName">FileName for the destination file</param>
protected void MoveFileByRelativePath(string sourceRelativeFilePath, string destRelativeFilePath, string destinationFileName)
{
if (FileExistsByRelativePath(sourceRelativeFilePath))
if (!FileExistsByRelativePath(sourceRelativeFilePath))
{
Guard.AssertNotNullOrEmpty(sourceRelativeFilePath, nameof(sourceRelativeFilePath));
throw new FileNotFoundException($"File {sourceRelativeFilePath} does not exist.");
}

string sourceAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeFilePath);
string destAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeFilePath);
string destAbsoluteParentDirPath = destAbsoluteFilePath.Remove(destAbsoluteFilePath.IndexOf(destinationFileName, StringComparison.Ordinal));
Directory.CreateDirectory(destAbsoluteParentDirPath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteFilePath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteFilePath);
if (FileExistsByRelativePath(destRelativeFilePath))
{
throw new IOException($"Suggested file name {destinationFileName} already exists.");
}
string sourceAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeFilePath);
string destAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeFilePath);
string destAbsoluteParentDirPath = destAbsoluteFilePath.Remove(destAbsoluteFilePath.IndexOf(destinationFileName, StringComparison.Ordinal));
Directory.CreateDirectory(destAbsoluteParentDirPath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteFilePath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteFilePath);

File.Move(sourceAbsoluteFilePath, destAbsoluteFilePath);
}

File.Move(sourceAbsoluteFilePath, destAbsoluteFilePath);
/// <summary>
/// Move the specified folder to specified destination
/// </summary>
/// <param name="sourceRelativeDirectoryPath">Relative path to folder to be moved.</param>
/// <param name="destRelativeDirectoryPath">Relative path to destination of moved folder.</param>
protected void MoveDirectoryByRelativePath(string sourceRelativeDirectoryPath, string destRelativeDirectoryPath)
{
if (!DirectoryExistsByRelativePath(sourceRelativeDirectoryPath))
{
throw new DirectoryNotFoundException($"Directory {sourceRelativeDirectoryPath} does not exist.");
}
if (DirectoryExistsByRelativePath(destRelativeDirectoryPath))
{
throw new IOException($"Suggested directory {destRelativeDirectoryPath} already exists.");
}
string sourceAbsoluteDirectoryPath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeDirectoryPath);
string destAbsoluteDirectoryPath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeDirectoryPath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteDirectoryPath);
Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteDirectoryPath);

Directory.Move(sourceAbsoluteDirectoryPath, destAbsoluteDirectoryPath);
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions backend/src/Designer/Infrastructure/MvcConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Altinn.Studio.Designer.Filters.DataModeling;
using Altinn.Studio.Designer.Filters.Git;
using Altinn.Studio.Designer.Filters.IO;
using Altinn.Studio.Designer.Filters.Options;
using Altinn.Studio.Designer.ModelBinding;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -26,6 +27,7 @@ public static IServiceCollection ConfigureMvc(this IServiceCollection services)
options.Filters.Add(typeof(DataModelingExceptionFilterAttribute));
options.Filters.Add(typeof(GitExceptionFilterAttribute));
options.Filters.Add(typeof(IoExceptionFilterAttribute));
options.Filters.Add(typeof(OptionsExceptionFilterAttribute));
})
.AddNewtonsoftJson(options => options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter()));

Expand Down
11 changes: 5 additions & 6 deletions backend/src/Designer/Models/Option.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Altinn.Studio.Designer.Helpers.JsonConverterHelpers;

Expand All @@ -12,17 +11,17 @@ public class Option
/// <summary>
/// Value that connects the option to the data model.
/// </summary>
[Required]
[NotNullable]
[JsonPropertyName("value")]
[JsonConverter(typeof(OptionConverter))]
public object Value { get; set; }
[JsonConverter(typeof(OptionValueConverter))]
public required object Value { get; set; }

/// <summary>
/// Label to present to the user.
/// </summary>
[Required]
[NotNullable]
[JsonPropertyName("label")]
public string Label { get; set; }
public required string Label { get; set; }

/// <summary>
/// Description, typically displayed below the label.
Expand Down
36 changes: 22 additions & 14 deletions backend/src/Designer/Services/Implementation/OptionsService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Exceptions.Options;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
using LibGit2Sharp;
Expand Down Expand Up @@ -50,9 +51,26 @@ public async Task<List<Option>> GetOptionsList(string org, string repo, string d

string optionsListString = await altinnAppGitRepository.GetOptionsList(optionsListId, cancellationToken);
var optionsList = JsonSerializer.Deserialize<List<Option>>(optionsListString);

try
{
optionsList.ForEach(ValidateOption);
}
catch (ValidationException)
{
throw new InvalidOptionsFormatException($"One or more of the options have an invalid format in option list: {optionsListId}.");
}


return optionsList;
}

private void ValidateOption(Option option)
{
var validationContext = new ValidationContext(option);
Validator.ValidateObject(option, validationContext, validateAllProperties: true);
}

/// <inheritdoc />
public async Task<List<Option>> CreateOrOverwriteOptionsList(string org, string repo, string developer, string optionsListId, List<Option> payload, CancellationToken cancellationToken = default)
{
Expand All @@ -73,10 +91,10 @@ public async Task<List<Option>> UploadNewOption(string org, string repo, string
List<Option> deserializedOptions = JsonSerializer.Deserialize<List<Option>>(payload.OpenReadStream(),
new JsonSerializerOptions { WriteIndented = true, AllowTrailingCommas = true });

IEnumerable<Option> result = deserializedOptions.Where(option => IsNullOrEmptyOptionValue(option) || string.IsNullOrEmpty(option.Label));
if (result.Any())
bool optionListHasInvalidNullFields = deserializedOptions.Exists(option => option.Value == null || option.Label == null);
if (optionListHasInvalidNullFields)
{
throw new JsonException("Uploaded file is missing one of the following attributes for an option: value or label.");
throw new InvalidOptionsFormatException("Uploaded file is missing one of the following attributes for an option: value or label.");
}

var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);
Expand All @@ -85,16 +103,6 @@ public async Task<List<Option>> UploadNewOption(string org, string repo, string
return deserializedOptions;
}

bool IsNullOrEmptyOptionValue(object value)
{
if (value == null)
{
return true;
}

return value is string stringOption && string.IsNullOrEmpty(stringOption);
}

/// <inheritdoc />
public void DeleteOptionsList(string org, string repo, string developer, string optionsListId)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public async Task GetOptionsListIds_ShouldReturnOk(string org, string app, strin
{
{ "other-options" },
{ "test-options" },
{ "options-with-null-fields" },
};

string url = $"{VersionPrefix(org, targetRepository)}/option-list-ids";
Expand All @@ -39,7 +40,7 @@ public async Task GetOptionsListIds_ShouldReturnOk(string org, string app, strin

string responseContent = await response.Content.ReadAsStringAsync();
List<string> responseList = JsonSerializer.Deserialize<List<string>>(responseContent);
responseList.Count.Should().Be(2);
responseList.Count.Should().Be(3);
foreach (string id in expectedOptionsListIds)
{
responseList.Should().Contain(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Filters;
using Altinn.Studio.Designer.Models;
using Designer.Tests.Controllers.ApiTests;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

Expand Down Expand Up @@ -96,4 +98,26 @@ public async Task GetSingleOptionsList_Returns404NotFound_WhenOptionsListDoesNot
// Assert
Assert.Equal(StatusCodes.Status404NotFound, (int)response.StatusCode);
}

[Fact]
public async Task GetSingleOptionsList_Returns400BadRequest_WhenOptionsListIsInvalid()
{
// Arrange
const string repo = "app-with-options";
const string optionsListId = "options-with-null-fields";

string apiUrl = $"/designer/api/ttd/{repo}/options/{optionsListId}";
using HttpRequestMessage httpRequestMessage = new(HttpMethod.Get, apiUrl);

// Act
using HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage);

// Assert
Assert.Equal(StatusCodes.Status400BadRequest, (int)response.StatusCode);

var problemDetails = JsonSerializer.Deserialize<ProblemDetails>(await response.Content.ReadAsStringAsync());
problemDetails.Should().NotBeNull();
JsonElement errorCode = (JsonElement)problemDetails.Extensions[ProblemDetailsExtensionsCodes.ErrorCode];
errorCode.ToString().Should().Be("InvalidOptionsFormat");
}
}
Loading

0 comments on commit 6d74368

Please sign in to comment.